• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

activescott / serverless-aws-static-file-handler / 3927194996

pending completion
3927194996

Pull #216

github

GitHub
Merge bffa99c60 into 0c62939cb
Pull Request #216: build: deployment auto-approve workflow

55 of 61 branches covered (90.16%)

Branch coverage included in aggregate %.

122 of 134 relevant lines covered (91.04%)

31.41 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.67
/src/StaticFileHandler.js
1
"use strict"
2
const assert = require("assert")
3✔
3
const fs = require("fs")
3✔
4
const mimetypes = require("mime-types")
3✔
5
const Mustache = require("mustache")
3✔
6
const path = require("path")
3✔
7
const util = require("util")
3✔
8
const readFileAsync = util.promisify(fs.readFile)
3✔
9
const accessAsync = util.promisify(fs.access)
3✔
10

11
// originally from lodash, but never called with a defaultValue
12
// https://gist.github.com/jeneg/9767afdcca45601ea44930ea03e0febf
13
function __get(value, path, defaultValue) {
14
  return String(path)
78✔
15
    .split(".")
16
    .reduce((acc, v) => {
17
      if (v.startsWith("[")) {
162!
18
        const [, arrPart] = v.split("[")
×
19
        v = arrPart.split("]")[0]
×
20
      }
21

22
      if (v.endsWith("]") && !v.startsWith("[")) {
162!
23
        const [objPart, arrPart, ...rest] = v.split("[")
×
24
        const [firstIndex] = arrPart.split("]")
×
25
        const otherParts = rest
×
26
          .join("")
27
          .replaceAll("[", "")
28
          .replaceAll("]", ".")
29
          .split(".")
30
          .filter((str) => str !== "")
×
31

32
        return [...acc, objPart, firstIndex, ...otherParts]
×
33
      }
34

35
      return [...acc, v]
162✔
36
    }, [])
37
    .reduce((acc, v) => {
38
      try {
162✔
39
        acc = acc[v] !== undefined ? acc[v] : defaultValue
162✔
40
      } catch (e) {
41
        return defaultValue
12✔
42
      }
43

44
      return acc
150✔
45
    }, value)
46
}
47

48
class StaticFileHandler {
49
  /**
50
   * Initializes a new instance of @see StaticFileHandler
51
   * @param {*string} clientFilesPath The fully qualified path to the client files that this module should serve.
52
   * @param {*string} customErrorPagePath Optional path to a custom error page. Must be relative to @see clientFilesPath .
53
   */
54
  constructor(clientFilesPath, customErrorPagePath = null) {
66✔
55
    if (clientFilesPath == null || clientFilesPath.length === 0) {
75✔
56
      throw new Error("clientFilesPath must be specified")
3✔
57
    }
58
    this.clientFilesPath = clientFilesPath
72✔
59
    this.customErrorPagePath = customErrorPagePath
72✔
60
  }
61

62
  static getMimeType(filePath) {
63
    return mimetypes.lookup(filePath) || "application/octet-stream"
48✔
64
  }
65

66
  static isBinaryType(mimeType) {
67
    const mimeCharset = mimetypes.charset(mimeType)
48✔
68
    /* Using https://w3techs.com/technologies/overview/character_encoding/all
69
     * to be more comprehensive go through those at https://www.iana.org/assignments/character-sets/character-sets.xhtml
70
     */
71
    const textualCharSets = [
48✔
72
      "UTF-8",
73
      "ISO-8859-1",
74
      "Windows-1251",
75
      "Windows-1252",
76
      "Shift_JIS",
77
      "GB2312",
78
      "EUC-KR",
79
      "ISO-8859-2",
80
      "GBK",
81
      "Windows-1250",
82
      "EUC-JP",
83
      "Big5",
84
      "ISO-8859-15",
85
      "Windows-1256",
86
      "ISO-8859-9",
87
    ]
88
    const found = textualCharSets.find(
48✔
89
      (cs) => 0 === cs.localeCompare(mimeCharset, "en", { sensitivity: "base" })
258✔
90
    )
91
    return found === undefined || found === null
48✔
92
  }
93

94
  async get(event, context) {
95
    if (!event) {
69✔
96
      throw new Error("event object not specified.")
3✔
97
    }
98

99
    if (event.rawPath) {
66✔
100
      // convert the V2 API to look like v1 like rest of code expects
101

102
      // this matches validateLambdaProxyIntegration required props
103
      event.resource = event.requestContext.http.path
6✔
104
      event.path = event.rawPath
6✔
105
      event.httpMethod = event.requestContext.http.method
6✔
106
      // OK as is event.headers
107
      event.multiValueHeaders = event.headers
6✔
108
      event.queryStringParameters = event.queryStringParamaters
6✔
109
      event.multiValueQueryStringParameters = event.queryStringParameters // Not sure what old code does ?
6✔
110
      // OK as is event.pathParameters
111
      event.stageVariables = event.requestContext.stage // Not sure we ever pass these ?
6✔
112
      // OK as is event.requestContext
113
      event.body = "" // It is a GET, there never is one ?
6✔
114
      // OK as is event.isBase64Encoded
115
    }
116

117
    if (!event.path) {
66✔
118
      throw new Error("Empty path.")
3✔
119
    }
120
    await StaticFileHandler.validateLambdaProxyIntegration(event)
63✔
121
    let requestPath
122
    if (event.pathParameters) {
60✔
123
      requestPath = ""
9✔
124
      /*
125
       * event.path is an object when `integration: lambda` and there is a greedy path parameter
126
       * If there are zero properties, it is just "lambda integration" and no path parameters
127
       * If there are properties, it indicates there are path parameters.
128
       * For example: The path parameter could be mapped like so in serverless.yml:
129
       * - http:
130
             path: fontsdir/{fonts+}
131
       * The {fonts+} in the path indicates the base path and tells APIG to pass along the whole path.
132
       */
133
      // now enumerate the properties of it:
134
      let propNames = Object.getOwnPropertyNames(event.pathParameters)
9✔
135
      if (propNames.length === 0) {
9!
136
        const msg =
137
          "The event.path is an object but there are no properties. Check serverless.yml."
×
138
        throw new Error(msg)
×
139
      }
140
      if (propNames.length !== 1) {
9!
141
        const msg = `Expected exactly one property name, but found: ${util.inspect(
×
142
          propNames
143
        )}. Check that you configured the pathParameter in serverless.yml with a plus sign like \`path/{pathparam+}\`.`
144
        throw new Error(msg)
×
145
      }
146
      requestPath = "/" + event.pathParameters[propNames[0]]
9✔
147
    } else {
148
      assert(typeof event.path === "string", "expected path to be string")
51✔
149
      requestPath = event.path
51✔
150
    }
151
    let filePath = path.join(this.clientFilesPath, requestPath)
60✔
152
    return this.readFileAsResponse(filePath, context).catch((err) => {
60✔
153
      throw new Error(
×
154
        `Unable to read client file '${requestPath}'. Error: ${err}`
155
      )
156
    })
157
  }
158

159
  /**
160
   * Loads the specified file's content and returns a response that can be called back to lambda for sending the file as the http response.
161
   */
162
  async readFileAsResponse(filePath, context, statusCode = 200) {
60✔
163
    let stream
164
    try {
66✔
165
      stream = await readFileAsync(filePath)
66✔
166
    } catch (err) {
167
      if (err.code === "ENOENT") {
18!
168
        // NOTE: avoid leaking full local path
169
        const fileName = path.basename(filePath)
18✔
170
        return this.responseAsError(`File ${fileName} does not exist`, 404)
18✔
171
      }
172
    }
173
    let mimeType = StaticFileHandler.getMimeType(filePath)
48✔
174
    return StaticFileHandler.readStreamAsResponse(
48✔
175
      stream,
176
      context,
177
      statusCode,
178
      mimeType
179
    )
180
  }
181

182
  static readStreamAsResponse(stream, context, statusCode, mimeType) {
183
    let body
184
    let isBase64Encoded = false
48✔
185
    if (StaticFileHandler.isBinaryType(mimeType)) {
48✔
186
      isBase64Encoded = true
15✔
187
      body = Buffer.from(stream).toString("base64")
15✔
188
    } else {
189
      body = stream.toString("utf8")
33✔
190
    }
191
    return StaticFileHandler.readStringAsResponse(
48✔
192
      body,
193
      context,
194
      statusCode,
195
      mimeType,
196
      isBase64Encoded
197
    )
198
  }
199

200
  static readStringAsResponse(
201
    stringData,
202
    context,
203
    statusCode,
204
    mimeType,
205
    isBase64Encoded
206
  ) {
207
    assert(mimeType, "expected mimeType to always be provided")
60✔
208
    if (
60✔
209
      context &&
102✔
210
      "staticFileHandler" in context &&
211
      "viewData" in context.staticFileHandler
212
    ) {
213
      const viewData = context.staticFileHandler.viewData
21✔
214
      stringData = Mustache.render(stringData, viewData)
21✔
215
    }
216
    const response = {
60✔
217
      statusCode: statusCode,
218
      headers: {
219
        "Content-Type": mimeType,
220
      },
221
      isBase64Encoded,
222
      body: stringData,
223
    }
224
    return response
60✔
225
  }
226

227
  /**
228
   * Returns a Promise with a response that is an HTML page with the specified error text on it.
229
   * @param {*string} errorText The error to add to the page.
230
   */
231
  async responseAsError(errorText, statusCode) {
232
    const context = {
18✔
233
      staticFileHandler: {
234
        viewData: {
235
          errorText: errorText,
236
        },
237
      },
238
    }
239
    if (this.customErrorPagePath) {
18✔
240
      let filePath = path.join(this.clientFilesPath, this.customErrorPagePath)
9✔
241
      try {
9✔
242
        await accessAsync(filePath, fs.constants.R_OK)
9✔
243
        return this.readFileAsResponse(filePath, context, statusCode)
6✔
244
      } catch (err) {
245
        console.warn(
3✔
246
          "serverless-aws-static-file-handler: Error using customErrorPagePath",
247
          this.customErrorPagePath,
248
          ". Using fallback error HTML."
249
        )
250
      }
251
    }
252

253
    const DEFAULT_ERROR_HTML = `<!DOCTYPE html>
12✔
254
<html lang="en">
255
  <head>
256
    <meta charset="UTF-8" />
257
    <title>Error</title>
258
  </head>
259

260
  <body>
261
    {{errorText}}
262
  </body>
263
</html>
264
`
265
    return StaticFileHandler.readStringAsResponse(
12✔
266
      DEFAULT_ERROR_HTML,
267
      context,
268
      statusCode,
269
      "text/html",
270
      false
271
    )
272
  }
273

274
  /**
275
   * Rejects if the specified event is not Lambda Proxy integration
276
   */
277
  static async validateLambdaProxyIntegration(event) {
278
    /* 
279
    There are two different event schemas in API Gateway + Lambda Proxy APIs. One is known as "REST API" or the old V1 API and the newer one is the V2 or "HTTP API". 
280
    Each are described at https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html#services-apigateway-apitypes
281
    You can see examples of each at https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
282
    TLDR: 
283
    - V2 has a { version: "2.0" } field and { requestContext.http.method: "..." }  field.
284
    - V1 has a { version: "1.0" } field and { requestContext.httpMethod: "..." }  field.
285
    To set each up in serverless.com:
286
    - V1: https://www.serverless.com/framework/docs/providers/aws/events/apigateway
287
    - V2: https://www.serverless.com/framework/docs/providers/aws/events/http-api
288
    */
289
    function isV2ProxyAPI(evt) {
290
      return (
9✔
291
        evt.version === "2.0" &&
15✔
292
        typeof __get(evt, "requestContext.http.method") === "string"
293
      )
294
    }
295
    function isV1ProxyAPI(evt) {
296
      return (
63✔
297
        // docs say there is a .version but there isn't!
298
        // evt.version === "1.0" &&
299
        typeof __get(evt, "requestContext.httpMethod") === "string"
300
      )
301
    }
302
    // serverless-offline doesn't provide the `isBase64Encoded` prop, but does add the isOffline. Fixes issue #10: https://github.com/activescott/serverless-aws-static-file-handler/issues/10
303
    const isServerlessOfflineEnvironment = "isOffline" in event
63✔
304
    if (!isV1ProxyAPI(event) && !isV2ProxyAPI(event)) {
63✔
305
      const logProps = [
3✔
306
        "version",
307
        "requestContext.httpMethod",
308
        "requestContext.http.method",
309
      ]
310
      const addendum = logProps
3✔
311
        .map((propName) => `event.${propName} was '${__get(event, propName)}'`)
9✔
312
        .join(" ")
313
      throw new Error(
3✔
314
        "API Gateway method does not appear to be setup for Lambda Proxy Integration. Please confirm that `integration` property of the http event is not specified or set to `integration: proxy`." +
315
          addendum
316
      )
317
    }
318
  }
319
}
320

321
module.exports = StaticFileHandler
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc