forked from kahmali/meteor-restivus
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathroute.coffee
259 lines (213 loc) · 9.22 KB
/
route.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
class share.Route
constructor: (@api, @path, @options, @endpoints) ->
# Check if options were provided
if not @endpoints
@endpoints = @options
@options = {}
addToApi: do ->
availableMethods = ['get', 'post', 'put', 'patch', 'delete', 'options']
return ->
self = this
# Throw an error if a route has already been added at this path
# TODO: Check for collisions with paths that follow same pattern with different parameter names
if _.contains @api._config.paths, @path
throw new Error "Cannot add a route at an existing path: #{@path}"
# Override the default OPTIONS endpoint with our own
@endpoints = _.extend options: @api._config.defaultOptionsEndpoint, @endpoints
# Configure each endpoint on this route
@_resolveEndpoints()
@_configureEndpoints()
# Add to our list of existing paths
@api._config.paths.push @path
allowedMethods = _.filter availableMethods, (method) ->
_.contains(_.keys(self.endpoints), method)
rejectedMethods = _.reject availableMethods, (method) ->
_.contains(_.keys(self.endpoints), method)
# Setup endpoints on route
fullPath = @api._config.apiPath + @path
_.each allowedMethods, (method) ->
endpoint = self.endpoints[method]
JsonRoutes.add method, fullPath, (req, res) ->
# Add function to endpoint context for indicating a response has been initiated manually
responseInitiated = false
doneFunc = ->
responseInitiated = true
endpointContext =
urlParams: req.params
queryParams: req.query
bodyParams: req.body
request: req
response: res
done: doneFunc
# Add endpoint config options to context
_.extend endpointContext, endpoint
# Run the requested endpoint
responseData = null
try
responseData = self._callEndpoint endpointContext, endpoint
catch error
# Do exactly what Iron Router would have done, to avoid changing the API
ironRouterSendErrorToResponse(error, req, res);
return
if responseInitiated
# Ensure the response is properly completed
res.end()
return
else
if res.headersSent
throw new Error "Must call this.done() after handling endpoint response manually: #{method} #{fullPath}"
else if responseData is null or responseData is undefined
throw new Error "Cannot return null or undefined from an endpoint: #{method} #{fullPath}"
# Generate and return the http response, handling the different endpoint response types
if responseData.body and (responseData.statusCode or responseData.headers)
self._respond res, responseData.body, responseData.statusCode, responseData.headers
else
self._respond res, responseData
_.each rejectedMethods, (method) ->
JsonRoutes.add method, fullPath, (req, res) ->
responseData = status: 'error', message: 'API endpoint does not exist'
headers = 'Allow': allowedMethods.join(', ').toUpperCase()
self._respond res, responseData, 405, headers
###
Convert all endpoints on the given route into our expected endpoint object if it is a bare
function
@param {Route} route The route the endpoints belong to
###
_resolveEndpoints: ->
_.each @endpoints, (endpoint, method, endpoints) ->
if _.isFunction(endpoint)
endpoints[method] = {action: endpoint}
return
###
Configure the authentication and role requirement on all endpoints (except OPTIONS, which must
be configured directly on the endpoint)
Authentication can be required on an entire route or individual endpoints. If required on an
entire route, that serves as the default. If required in any individual endpoints, that will
override the default.
After the endpoint is configured, all authentication and role requirements of an endpoint can be
accessed at <code>endpoint.authRequired</code> and <code>endpoint.roleRequired</code>,
respectively.
@param {Route} route The route the endpoints belong to
@param {Endpoint} endpoint The endpoint to configure
###
_configureEndpoints: ->
_.each @endpoints, (endpoint, method) ->
if method isnt 'options'
# Configure acceptable roles
if not @options?.roleRequired
@options.roleRequired = []
if not endpoint.roleRequired
endpoint.roleRequired = []
endpoint.roleRequired = _.union endpoint.roleRequired, @options.roleRequired
# Make it easier to check if no roles are required
if _.isEmpty endpoint.roleRequired
endpoint.roleRequired = false
# Configure auth requirement
if endpoint.authRequired is undefined
if @options?.authRequired or endpoint.roleRequired
endpoint.authRequired = true
else
endpoint.authRequired = false
return
, this
return
###
Authenticate an endpoint if required, and return the result of calling it
@returns The endpoint response or a 401 if authentication fails
###
_callEndpoint: (endpointContext, endpoint) ->
# Call the endpoint if authentication doesn't fail
if @_authAccepted endpointContext, endpoint
if @_roleAccepted endpointContext, endpoint
endpoint.action.call endpointContext
else
statusCode: 403
body: {status: 'error', message: 'You do not have permission to do this.'}
else
statusCode: 401
body: {status: 'error', message: 'You must be logged in to do this.'}
###
Authenticate the given endpoint if required
Once it's globally configured in the API, authentication can be required on an entire route or
individual endpoints. If required on an entire endpoint, that serves as the default. If required
in any individual endpoints, that will override the default.
@returns False if authentication fails, and true otherwise
###
_authAccepted: (endpointContext, endpoint) ->
if endpoint.authRequired
@_authenticate endpointContext
else true
###
Verify the request is being made by an actively logged in user
If verified, attach the authenticated user to the context.
@returns {Boolean} True if the authentication was successful
###
_authenticate: (endpointContext) ->
# Get auth info
auth = @api._config.auth.user.call(endpointContext)
# Get the user from the database
if auth?.userId and auth?.token and not auth?.user
userSelector = {}
userSelector._id = auth.userId
userSelector[@api._config.auth.token] = auth.token
auth.user = Meteor.users.findOne userSelector
# Attach the user and their ID to the context if the authentication was successful
if auth?.user
endpointContext.user = auth.user
endpointContext.userId = auth.user._id
true
else false
###
Authenticate the user role if required
Must be called after _authAccepted().
@returns True if the authenticated user belongs to <i>any</i> of the acceptable roles on the
endpoint
###
_roleAccepted: (endpointContext, endpoint) ->
if endpoint.roleRequired
if _.isEmpty _.intersection(endpoint.roleRequired, endpointContext.user.roles)
return false
true
###
Respond to an HTTP request
###
_respond: (response, body, statusCode=200, headers={}) ->
# Override any default headers that have been provided (keys are normalized to be case insensitive)
# TODO: Consider only lowercasing the header keys we need normalized, like Content-Type
defaultHeaders = @_lowerCaseKeys @api._config.defaultHeaders
headers = @_lowerCaseKeys headers
headers = _.extend defaultHeaders, headers
# Prepare JSON body for response when Content-Type indicates JSON type
if headers['content-type'].match(/json|javascript/) isnt null
if @api._config.prettyJson
body = JSON.stringify body, undefined, 2
else
body = JSON.stringify body
# Send response
sendResponse = ->
response.writeHead statusCode, headers
response.write body
response.end()
if statusCode in [401, 403]
# Hackers can measure the response time to determine things like whether the 401 response was
# caused by bad user id vs bad password.
# In doing so, they can first scan for valid user ids regardless of valid passwords.
# Delay by a random amount to reduce the ability for a hacker to determine the response time.
# See https://www.owasp.org/index.php/Blocking_Brute_Force_Attacks#Finding_Other_Countermeasures
# See https://en.wikipedia.org/wiki/Timing_attack
minimumDelayInMilliseconds = 500
randomMultiplierBetweenOneAndTwo = 1 + Math.random()
delayInMilliseconds = minimumDelayInMilliseconds * randomMultiplierBetweenOneAndTwo
Meteor.setTimeout sendResponse, delayInMilliseconds
else
sendResponse()
###
Return the object with all of the keys converted to lowercase
###
_lowerCaseKeys: (object) ->
_.chain object
.pairs()
.map (attr) ->
[attr[0].toLowerCase(), attr[1]]
.object()
.value()