diff --git a/docs/Tutorials/Compression/Requests.md b/docs/Tutorials/Compression/Requests.md index f5604a307..c6b66838b 100644 --- a/docs/Tutorials/Compression/Requests.md +++ b/docs/Tutorials/Compression/Requests.md @@ -1,24 +1,59 @@ # Requests -You can send Requests to your Pode server that use compression on the payload, such as a JSON payload compressed via GZip. +Pode supports sending requests with compressed payloads (such as JSON compressed with GZip), but it's important to use the correct approach for compression and decompression. Pode supports the following compression methods: * gzip * deflate +* br (Brotli, supported when running under PowerShell Core) -There are a number of ways you can specify the compression type, and these are defined below. When your request uses compression, Pode will first decompress the payload, and then attempt to parse it if needed. +## Important Note on Compression Headers -## Request +> **Note:** While Pode allows the use of the `Transfer-Encoding` header for compatibility with some clients, this is **not** the recommended way to compress request payloads. Pode does **not** decompress the payload on the fly using `Transfer-Encoding`. Instead, Pode expects compressed payloads to be indicated using the `Content-Encoding` header, which is the standard way for HTTP payload compression. -The most common way is to define the a request's compression type in the request's `Transfer-Endocing` header. +If you use `Transfer-Encoding`, Pode will not automatically decompress the request body. This option is only available for legacy or compatibility reasons. -An example of the header in the request is as follows: +## Recommended Approach: Content-Encoding Header + +To ensure Pode correctly decompresses the payload, use the `Content-Encoding` header in your requests. For example: + +```text +Content-Encoding: gzip +Content-Encoding: deflate +``` + +Pode will automatically decompress the payload if this header is present and matches a supported encoding. + +## Enabling Compression on Routes + +To configure how Pode handles compressed payloads, use the `Add-PodeRouteCompression` function. This allows you to enable or disable compression for specific routes and specify which encodings are supported. For example: + +```powershell +Add-PodeRoute -Method Post -Path '/upload' -ScriptBlock { ... } -PassThru | + Add-PodeRouteCompression -Enable -Encoding gzip,deflate -Direction Request +``` + +**Parameters for `Add-PodeRouteCompression`:** + +- `-Enable` : Enables compression for the specified route(s). +- `-Disable` : Disables compression for the specified route(s). +- `-Encoding ` : Specifies one or more compression algorithms to allow. Valid values are: `gzip`, `deflate`, and `br` (Brotli, PowerShell Core only). +- `-Direction ` : Sets the direction of compression. Valid values are: + - `Request` (decompress incoming requests) + - `Response` (compress outgoing responses, default) + - `Both` (enable for both requests and responses) +- `-PassThru` : Returns the updated route object(s) to the pipeline for further processing. + +This will ensure that incoming requests to `/upload` are decompressed if they use `Content-Encoding: gzip`, `Content-Encoding: deflate`, or `Content-Encoding: br` (Brotli, PowerShell Core only). + +## Legacy/Compatibility: Transfer-Encoding Header + +Pode still supports the `Transfer-Encoding` header for compatibility with some clients, but this is not the preferred method. If you use this header, Pode will not decompress the payload automatically. Example: ```text Transfer-Encoding: gzip Transfer-Encoding: deflate - // or: Transfer-Encoding: gzip,chunked ``` @@ -35,7 +70,7 @@ Add-PodeRoute -Method Get -Path '/' -TransferEncoding gzip -ScriptBlock { ## Configuration -Using the `server.psd1` configuration file, you can define a default transfer encoding to use for every route, or you can define patterns to match multiple route paths to set transfer encodings on mass. +Pode also supports configuring default or pattern-based compression behavior in your `server.psd1` configuration file. This allows you to set a default transfer encoding for all routes, or define patterns to match multiple route paths and set transfer encodings in bulk. ### Default @@ -70,18 +105,43 @@ For example, the following configuration in your `server.psd1` would bind all `/ } ``` -## Precedence +> **Note:** These configuration options are primarily for compatibility and legacy support. For new projects, prefer using `Add-PodeRouteCompression` and the `Content-Encoding` header as described above. -The transfer encoding that will be used is determined by the following order: +## Example: Sending a GZip Encoded Payload (Legacy) -1. Being defined on the Route. -2. The Route matches a pattern defined in the configuration file. -3. A default transfer encoding is defined in the configuration file. -4. The transfer encoding is supplied on the web request. +The following is an example of sending a `gzip` encoded payload using the legacy `Transfer-Encoding` header: -## Example +```powershell +# get the JSON message in bytes +$data = @{ + Name = "Deepthought" + Age = 42 +} -The following is an example of sending a `gzip` encoded payload to some `/ping` route: +$message = ($data | ConvertTo-Json) +$bytes = [System.Text.Encoding]::UTF8.GetBytes($message) + +# compress the message using gzip +$ms = [System.IO.MemoryStream]::new() +$gzip = [System.IO.Compression.GZipStream]::new($ms, [IO.Compression.CompressionMode]::Compress, $true) +$gzip.Write($bytes, 0, $bytes.Length) +$gzip.Close() +$ms.Position = 0 + +# send request +Invoke-RestMethod ` + -Method Post ` + -Uri 'http://localhost:8080/ping' ` + -Body $ms.ToArray() ` + -TransferEncoding gzip ` + -ContentType application/json +``` + +This will ensure Pode correctly decompresses and processes the payload when using legacy `Transfer-Encoding` (not recommended for new projects). + +# Example: Sending a GZip Encoded Payload (Recommended) + +The following is an example of sending a `gzip` encoded payload using the recommended `Content-Encoding` header: ```powershell # get the JSON message in bytes @@ -105,6 +165,8 @@ Invoke-RestMethod ` -Method Post ` -Uri 'http://localhost:8080/ping' ` -Body $ms.ToArray() ` - -TransferEncoding gzip ` + -Headers @{ 'Content-Encoding' = 'gzip' } ` -ContentType application/json ``` + +This will ensure Pode correctly decompresses and processes the payload using the modern and recommended approach. \ No newline at end of file diff --git a/docs/Tutorials/Compression/Responses.md b/docs/Tutorials/Compression/Responses.md index f3c78527a..bc368b853 100644 --- a/docs/Tutorials/Compression/Responses.md +++ b/docs/Tutorials/Compression/Responses.md @@ -2,36 +2,39 @@ Pode has support for sending back compressed Responses, if enabled, and if a client sends an appropriate `Accept-Encoding` header. -The followings compression methods are supported: +The following compression methods are supported: * gzip * deflate +* br (Brotli, supported when running under PowerShell Core) -When enabled, Pode will compress the response's bytes prior to sending the response; the `Content-Encoding` header will also be sent appropriately on the response. +## Enabling Response Compression with Add-PodeRouteCompression -## Enable - -By default response compression is disabled in Pode. To enable compression you can set the following value in your server's `server.psd1` [configuration](../../Configuration) file: +The recommended way to enable response compression is to use the `Add-PodeRouteCompression` function when defining your routes. This allows you to enable or disable compression for specific routes and specify which encodings are supported. ```powershell -@{ - Web = @{ - Compression = @{ - Enable = $true - } - } -} +Add-PodeRoute -Method Get -Path '/data' -ScriptBlock { ... } -PassThru | + Add-PodeRouteCompression -Enable -Encoding gzip,deflate,br -Direction Response ``` -Once enabled, compression will be used if a valid `Accept-Encoding` header is sent in the request. +**Parameters for `Add-PodeRouteCompression`:** + +* `-Enable` : Enables compression for the specified route(s). +* `-Disable` : Disables compression for the specified route(s). +* `-Encoding ` : Specifies one or more compression algorithms to allow. Valid values are: `gzip`, `deflate`, and `br` (Brotli, PowerShell Core only). +* `-Direction ` : Sets the direction of compression. Use `Response` to compress outgoing responses (default). +* `-PassThru` : Returns the updated route object(s) to the pipeline for further processing. + +This will ensure that outgoing responses from `/data` are compressed if the client sends an `Accept-Encoding` header with a supported encoding. ## Headers -For your Pode server to compress the response, the client must send an `Accept-Encoding` header for with `gzip` or `deflate`: +For your Pode server to compress the response, the client must send an `Accept-Encoding` header with `gzip`, `deflate`, or `br` (PowerShell Core only): ```text Accept-Encoding: gzip Accept-Encoding: deflate +Accept-Encoding: br Accept-Encoding: identity Accept-Encoding: * ``` @@ -39,15 +42,31 @@ Accept-Encoding: * Or any valid combination: ```text -Accept-Encoding: gzip,deflate +Accept-Encoding: gzip,deflate,br ``` -If multiple encodings are sent, then Pode will use the first supported value. There is also support for quality values as well, so you can weight encodings or fully disable non-compression (if no q-value is on an encoding it is assumed to be 1) +If multiple encodings are sent, Pode will use the first supported value. There is also support for quality values as well, so you can weight encodings or fully disable non-compression (if no q-value is on an encoding it is assumed to be 1) ```text -Accept-Encoding: gzip,deflate,identity;q=0 +Accept-Encoding: gzip,deflate,br,identity;q=0 ``` -In a scenario where no encodings are supported, and identity (no-compression) is disabled, then Pode will respond with a 406. +In a scenario where no encodings are supported, and identity (no-compression) is disabled, Pode will respond with a 406. If an encoding is used to compress the response, then Pode will set the `Content-Encoding` on the response. + +## Legacy Approach: Configuration File + +By default, response compression is disabled in Pode. The legacy way to enable compression is to set the following value in your server's `server.psd1` [configuration](../../Configuration) file: + +```powershell +@{ + Web = @{ + Compression = @{ + Enable = $true + } + } +} +``` + +Once enabled, compression will be used if a valid `Accept-Encoding` header is sent in the request. \ No newline at end of file diff --git a/docs/Tutorials/Mime.md b/docs/Tutorials/Mime.md new file mode 100644 index 000000000..3802294e4 --- /dev/null +++ b/docs/Tutorials/Mime.md @@ -0,0 +1,72 @@ +# Responses + +Pode has support for sending back compressed Responses, if enabled, and if a client sends an appropriate `Accept-Encoding` header. + +The following compression methods are supported: + +* gzip +* deflate +* br (Brotli, supported when running under PowerShell Core) + +## Enabling Response Compression with Add-PodeRouteCompression + +The recommended way to enable response compression is to use the `Add-PodeRouteCompression` function when defining your routes. This allows you to enable or disable compression for specific routes and specify which encodings are supported. + +```powershell +Add-PodeRoute -Method Get -Path '/data' -ScriptBlock { ... } -PassThru | + Add-PodeRouteCompression -Enable -Encoding gzip,deflate,br -Direction Response +``` + +**Parameters for `Add-PodeRouteCompression`:** + +* `-Enable` : Enables compression for the specified route(s). +* `-Disable` : Disables compression for the specified route(s). +* `-Encoding ` : Specifies one or more compression algorithms to allow. Valid values are: `gzip`, `deflate`, and `br` (Brotli, PowerShell Core only). +* `-Direction ` : Sets the direction of compression. Use `Response` to compress outgoing responses (default). +* `-PassThru` : Returns the updated route object(s) to the pipeline for further processing. + +This will ensure that outgoing responses from `/data` are compressed if the client sends an `Accept-Encoding` header with a supported encoding. + +## Headers + +For your Pode server to compress the response, the client must send an `Accept-Encoding` header with `gzip`, `deflate`, or `br` (PowerShell Core only): + +```text +Accept-Encoding: gzip +Accept-Encoding: deflate +Accept-Encoding: br +Accept-Encoding: identity +Accept-Encoding: * +``` + +Or any valid combination: + +```text +Accept-Encoding: gzip,deflate,br +``` + +If multiple encodings are sent, Pode will use the first supported value. There is also support for quality values as well, so you can weight encodings or fully disable non-compression (if no q-value is on an encoding it is assumed to be 1) + +```text +Accept-Encoding: gzip,deflate,br,identity;q=0 +``` + +In a scenario where no encodings are supported, and identity (no-compression) is disabled, Pode will respond with a 406. + +If an encoding is used to compress the response, then Pode will set the `Content-Encoding` on the response. + +## Legacy Approach: Configuration File + +By default, response compression is disabled in Pode. The legacy way to enable compression is to set the following value in your server's `server.psd1` [configuration](../../Configuration) file: + +```powershell +@{ + Web = @{ + Compression = @{ + Enable = $true + } + } +} +``` + +Once enabled, compression will be used if a valid `Accept-Encoding` header is sent in the request. diff --git a/docs/Tutorials/Routes/Utilities/Cache.md b/docs/Tutorials/Routes/Utilities/Cache.md new file mode 100644 index 000000000..f07f24b5f --- /dev/null +++ b/docs/Tutorials/Routes/Utilities/Cache.md @@ -0,0 +1,179 @@ +# Content Caching + +Pode supports caching of both static and non-static content to improve performance and reduce server load. Caching is only available for HTTP `GET` requests; `POST` and `PUT` methods are not supported for caching. + +Caching can be configured using two main approaches: + +- **Legacy configuration via `server.psd1`** +- **Modern, route-specific configuration using `Add-PodeRouteCache`** (recommended for new projects) + +--- + +## 1. Route-Specific Caching with `Add-PodeRouteCache` (Recommended) + +The `Add-PodeRouteCache` function allows you to enable and fine-tune caching for individual static routes. This provides greater flexibility and control compared to global configuration. + +### Example Usage + +```powershell +Add-PodeStaticRoute -Path '/cache' -Source $using:TestFolder -FileBrowser -PassThru | + Add-PodeRouteCache -Enable -MaxAge 10 -Visibility public -ETagMode mtime -MustRevalidate +``` + +#### Parameter Details (RFC Cache Settings) + +- `-Enable`: **Turns on caching for the route.** + - When enabled, Pode will add HTTP cache headers to responses for this route, allowing browsers and proxies to cache the content according to the specified settings. + +- `-MaxAge `: **Sets the cache duration in seconds.** + - This sets the `max-age` directive in the `Cache-Control` header, telling clients and intermediaries how long (in seconds) the content is considered fresh before it must be revalidated. + - Example: `Cache-Control: max-age=10` + +- `-Visibility `: **Sets the cache visibility and behavior.** + - `public`: The response may be cached by any cache (browser, proxy, CDN). + - `private`: The response is intended for a single user and should not be stored by shared caches (proxies, CDNs). + - `no-cache`: Forces caches to submit the request to the origin server for validation before releasing a cached copy. + - `no-store`: Prevents caches from storing any part of the request or response. + - Example: `Cache-Control: public` or `Cache-Control: private` + +- `-ETagMode `: **Controls how the ETag is generated.** + - `none`: Disables ETag generation. + - `auto`: Uses `mtime` for static routes and `hash` for dynamic routes (default behavior). + - `hash`: ETag is based on a hash of the file/content, useful for dynamic or API responses. + - `mtime`: ETag is based on the file's last modification time (recommended for static files). + - `manual`: Allows manual ETag setting via the `ETag` parameter of `Write-PodeTextResponse`, `Write-PodeYamlResponse`, `Write-PodeJsonResponse`, `Write-PodeCsvResponse`, `Write-PodeHtmlResponse`, and `Write-PodeXmlResponse` cmdlets. + - Example: `Write-PodeJsonResponse -Value $data -ETag 'my-custom-etag'` + +- `-MustRevalidate`: **Adds the `must-revalidate` directive to the cache headers.** + - This instructs caches that once the content becomes stale (after `max-age`), it must be revalidated with the server before being served again. This ensures clients always get up-to-date content after expiration. + - Example: `Cache-Control: must-revalidate` + +- `-Immutable`: **Adds the `immutable` directive to the cache headers.** + - Indicates that the resource will not change, so clients can use the cached version without revalidation for the duration of `max-age`. + - Example: `Cache-Control: immutable` + +- `-SharedMaxAge `: **Sets the `s-maxage` directive for shared (proxy) caches.** + - Controls how long shared caches (like CDNs or proxies) can cache the response, overriding `max-age` for those caches. + - Example: `Cache-Control: s-maxage=3600` + +- `-WeakValidation`: **Returns the ETag in weak form (prefixed with W/).** + - Indicates that the ETag is a weak validator, suitable for semantically equivalent but not byte-for-byte identical resources. + - Example: `ETag: W/""` + +You can chain `Add-PodeRouteCache` after any static route (or other supported routes) using the pipeline. This allows you to: + +- Enable/disable caching per route +- Set custom cache durations +- Control cache headers and validation +- Combine with compression (see below) + +#### Chaining with Compression + +You can also combine caching and compression: + +```powershell +Add-PodeStaticRoute -Path '/cache' -Source $using:TestFolder -FileBrowser -PassThru | + Add-PodeRouteCache -Enable -MaxAge 10 -Visibility public -ETagMode mtime -MustRevalidate -PassThru | + Add-PodeRouteCompression -Enable -Encoding gzip +``` + +## 2. Cache Control Headers + +Pode sets standard HTTP cache headers based on your configuration, such as: + +- `Cache-Control` +- `ETag` +- `Last-Modified` +- `Expires` + +These headers help browsers and proxies cache content efficiently and validate freshness. + +### Example: Full Server Reply Header with Caching + +Below is an example of a typical HTTP response header from Pode when caching is enabled for a static route: + +``` text +HTTP/1.1 200 OK +Content-Type: text/plain; charset=utf-8 +Content-Length: 12345 +Cache-Control: public, max-age=600, must-revalidate +ETag: "20250629-abcdef123456" +Last-Modified: Sat, 29 Jun 2025 10:00:00 GMT +Expires: Sat, 29 Jun 2025 10:10:00 GMT +Date: Sat, 29 Jun 2025 10:00:00 GMT +Vary: Accept-Encoding +``` + +- `Cache-Control`: Shows the cache policy (visibility, max-age, must-revalidate, etc.) +- `ETag`: Unique identifier for the file version (here based on modification time) +- `Last-Modified`: Timestamp of the file's last modification +- `Expires`: When the content should be considered stale +- `Vary`: Indicates which request headers affect the response (e.g., for compression) + +#### Conditional Requests and 304 Not Modified + +Pode supports conditional requests for efficient caching: + +- If the client sends an `If-None-Match` header with an ETag value that matches the current resource, the server responds with `304 Not Modified` and no body. +- If the client sends an `If-Modified-Since` header with a date that is equal to or newer than the resource's `Last-Modified` value, the server responds with `304 Not Modified` and no body. + +This allows clients and proxies to avoid downloading unchanged content, saving bandwidth and improving performance. + +## 3. Legacy Caching via `server.psd1` Configuration + +Pode also supports global static content caching via the `server.psd1` configuration file. This method applies cache settings to all static content unless overridden by route-specific settings. + +### Example Configuration + +```powershell +@{ + Web = @{ + Static = @{ + Cache = @{ + Enable = $true + MaxAge = 1800 # 30 minutes + Include = @( + "/images/*", + "/assets/*.js" + ) + Exclude = @( + "*.exe" + ) + } + } + } +} +``` + +- `Enable`: Turns on caching globally for static content. +- `MaxAge`: Sets the default cache duration (in seconds). +- `Include`: Only cache the specified paths/patterns. +- `Exclude`: Do not cache the specified paths/patterns. + +> **Note:** If you use both the configuration file and `Add-PodeRouteCache`, the route-specific settings take precedence for that route. + +## 4. More Examples + +### Basic Caching for a Static Route + +```powershell +Add-PodeStaticRoute -Path '/static' -Source './public' -PassThru | + Add-PodeRouteCache -Enable -MaxAge 600 +``` + +### Advanced Caching with Public Visibility and ETag + +```powershell +Add-PodeStaticRoute -Path '/assets' -Source './assets' -PassThru | + Add-PodeRouteCache -Enable -MaxAge 3600 -Visibility public -ETagMode mtime -MustRevalidate +``` + +--- + +**Tip:** Use `Add-PodeRouteCache` for fine-grained, per-route caching control. The global configuration file is suitable if all routes share the same, non-configurable cache settings. For more flexibility, you can apply caching to multiple routes programmatically, for example: + +```powershell +Get-PodeRoute | Add-PodeRouteCache -Enable -MaxAge 3600 -Visibility public -ETagMode mtime -MustRevalidate +``` + +This approach allows you to set or override cache settings for all or selected routes as needed. \ No newline at end of file diff --git a/docs/Tutorials/Routes/Utilities/StaticContent.md b/docs/Tutorials/Routes/Utilities/StaticContent.md index 0de06ec17..cb855e554 100644 --- a/docs/Tutorials/Routes/Utilities/StaticContent.md +++ b/docs/Tutorials/Routes/Utilities/StaticContent.md @@ -8,7 +8,6 @@ Caching is supported on static content. You can place static files within the `/public` directory at the root of your server, which serves as the default location for static content. When a request is made for a file, Pode will automatically check this designated static directory first, and if the file is found, it will be returned to the requester. - For example, if you have a `logic.js` at `/public/scripts/logic.js`. The following request would return the file's content: ```plain @@ -28,7 +27,7 @@ But if you need to relocate this directory, you can do so programmatically using Here is an example: -1. Using `Set-PodeStaticFolder` +#### Using `Set-PodeStaticFolder` ```powershell Set-PodeDefaultFolder -Type 'Public' -Path 'c:\custom\public' @@ -36,7 +35,7 @@ Set-PodeDefaultFolder -Type 'Views' -Path 'd:\shared\views' Set-PodeDefaultFolder -Type 'Errors' -Path 'e:\logs\errors' ``` -2. Using `server.psd1` configuration file +#### Using `server.psd1` configuration file ```powershell @{ @@ -96,12 +95,14 @@ Invoke-WebRequest -Uri 'http://localhost:8080/assets/images/home' -Method Get The default pages can be configured in two ways; either by using the `-Defaults` parameter on the [`Add-PodeStaticRoute`](../../../../Functions/Routes/Add-PodeStaticRoute) function, or by setting them in the `server.psd1` [configuration file](../../../Configuration). To set the defaults to be only a `home.html` page, both ways would work as follows: -*Defaults Parameter* +### Defaults Parameter + ```powershell Add-PodeStaticRoute -Path '/assets' -Source './content/assets' -Defaults @('index.html') ``` -*Configuration File* +### Configuration File + ```powershell @{ Web = @{ @@ -114,80 +115,6 @@ Add-PodeStaticRoute -Path '/assets' -Source './content/assets' -Defaults @('inde The only difference is, if you have multiple static routes, setting any default pages in the `server.psd1` file will apply to *all* static routes. Any default pages set using the `-Default` parameter will have a higher precedence than the `server.psd1` file. -## Caching - -Having web pages send requests to your Pode server for all static content every time can be quite a strain on the server. To help the server, you can enable static content caching, which will inform users' browsers to cache files (ie `*.css` and `*.js`) for so many seconds - stopping the browser from re-requesting it from your server each time. - -By default, caching is disabled and can be enabled and controlled using the `server.psd1` configuration file. - -To enable caching, with a default cache time of 1hr, you do: - -```powershell -@{ - Web = @{ - Static = @{ - Cache = @{ - Enable = $true - } - } - } -} -``` - -If you wish to set a max cache time of 30mins, then you would use the `MaxAge` property - setting it to `1800secs`: - -```powershell -@{ - Web = @{ - Static = @{ - Cache = @{ - Enable = $true - MaxAge = 1800 - } - } - } -} -``` - -### Include/Exclude - -Sometimes you don't want all static content to be cached, maybe you want `*.exe` files to always be re-requested? This is possible using the `Include` and `Exclude` properties in the `server.psd1`. - -Let's say you do want to exclude all `*.exe` files from being cached: - -```powershell -@{ - Web = @{ - Static = @{ - Cache = @{ - Enable = $true - Exclude = @( - "*.exe" - ) - } - } - } -} -``` - -Or, you could set up some static routes called `/assets` and `/images`, and you want everything on `/images` to be cached, but only `*.js` files to be cached on `/assets`: - -```powershell -@{ - Web = @{ - Static = @{ - Cache = @{ - Enable = $true - Include = @( - "/images/*", - "/assets/*.js" - ) - } - } - } -} -``` - ## Downloadable Normally content accessed on a static route is rendered on the browser, but you can set the route to flag the files for downloading instead. If you set the `-DownloadOnly` switch on the [Add-PodeStaticRoute`](../../../../Functions/Routes/Add-PodeStaticRoute) function, then accessing files on this route in a browser will cause them to be downloaded instead of rendered: @@ -236,4 +163,4 @@ Nothing to report :D } ``` -To change the default behaviour, you can use the `Web.Static.ValidateLast` property in the `server.psd1` configuration file, setting the value to `$True.` This will ensure that any static route is evaluated after any other route. +To change the default behaviour, you can use the `Web.Static.ValidateLast` property in the `server.psd1` configuration file, setting the value to `$True.` This will ensure that any static route is evaluated after any other route. \ No newline at end of file diff --git a/docs/Tutorials/WebEvent.md b/docs/Tutorials/WebEvent.md index aa8568129..899b3dd23 100644 --- a/docs/Tutorials/WebEvent.md +++ b/docs/Tutorials/WebEvent.md @@ -16,7 +16,7 @@ Add-PodeRoute -Method Get -Path '/' -ScriptBlock { It is advised not to directly alter these values, other than the ones through the documentation that say you can - such as Session Data. | Name | Type | Description | Docs | -| -------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +|----------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------| | Auth | hashtable | Contains the information on the currently authenticated user from the `Add-PodeAuth` methods - the user's details can be further accessed in the sub `.User` property | [link](../Authentication/Overview/#users) | | ContentType | string | The content type of the data in the Request's payload | n/a | | Cookies | hashtable | Contains all cookies parsed from the Request's headers - it's best to use Pode's Cookie functions to access/change Cookies | [link](../Cookies) | @@ -45,7 +45,7 @@ Add-PodeRoute -Method Get -Path '/' -ScriptBlock { These are the properties available for `$WebEvent.Endpoint` | Name | Type | Description | Docs | -| -------- | ------ | ------------------------------------------------------------------------ | ------------------------------------------- | +|----------|--------|--------------------------------------------------------------------------|---------------------------------------------| | Address | string | The ip/hostname being used for the Request. ie: 127.0.0.1 or example.com | n/a | | Name | string | The name of the Pode Endpoint being used for the Request | [link](../Endpoints/Basics/#endpoint-names) | | Protocol | string | The protocol being used for the Request. ie: HTTP, HTTPS, WS, WSS, etc. | n/a | @@ -55,7 +55,7 @@ These are the properties available for `$WebEvent.Endpoint` These are the properties available for `$WebEvent.StaticContent` | Name | Type | Description | Docs | -| ---------- | ------ | ----------------------------------------------------------- | ---- | +|------------|--------|-------------------------------------------------------------|------| | IsCachable | bool | Whether or not the file should be cached on the client side | n/a | | IsDownload | bool | Whether or not the file should be attached or rendered | n/a | | Source | string | The local path, using PSDrives, to the file on the server | n/a | @@ -71,11 +71,12 @@ These are the properties available for `$WebEvent.Request` Changing properties on this object could cause errors, unwanted behaviour, or a full server crash. Only edit them if you know what you're doing. Same for calling any methods. | Name | Type | Description | Example | -| ----------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | +|-------------------------|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| | Address | string | The address being used by the Request. This will favour hostnames over IPs | - | | AllowClientCertificate | bool | Whether Pode should expect, and process, and client certificates | - | | AwaitingBody | bool | If the request is chunked, this flags if Pode is still awaiting for the whole body to be sent | - | | Body | string | The textually encoded version of the RawBody | - | +| Cache | hashtable | Contains any cache data associated with the current request, if caching is enabled. | - | | Certificate | X509Certificate | The certificate being used for SSL connections. Usually defined from [`Add-PodeEndpoint`](../../Functions/Core/Add-PodeEndpoint) | - | | ClientCertificate | X509Certificate2 | If being used, the client certificate supplied on the Request | - | | ClientCertificateErrors | SslPolicyErrors | Contains any errors that might have occurred while validating the client certificate. Pode ignores these by default, so they will need checking the [Client Certificate Authentication](../Authentication/Methods/ClientCertificate) | - | @@ -104,7 +105,8 @@ These are the properties available for `$WebEvent.Request` | Scheme | string | The connection scheme being used | HTTP, HTTPS, etc. | | SslUpgraded | bool | Whether this connection has been upgraded to SSL. Used for implicit connections | - | | TlsMode | PodeTlsMode | Whether the connection is using implicit or explicit TLS | - | -| TransferEncoding | string | The transfer encoding used for the content | gzip, chunked, identity | +| TransferEncoding | string | The transfer encoding used for the content (Do not use it, as it is not RFC compliant) | gzip, chunked, identity | +| ContentEncoding | string | The transfer encoding used for the content | gzip, chunked, identity | | Url | Uri | The whole Request URL that was made | http://example.com?name=value | | UrlReferrer | string | The referred of the Request | - | | UserAgent | string | The user agent of where the Request originated | - | @@ -120,7 +122,7 @@ These are the properties available for `$WebEvent.Response` Changing properties on this object could cause errors, unwanted behaviour, or a full server crash. Only edit them if you know what you're doing. Same for calling any methods. | Name | Type | Description | Example | -| ----------------- | ------------------- | ---------------------------------------------------------------------------------------------------- | ------------------- | +|-------------------|---------------------|------------------------------------------------------------------------------------------------------|---------------------| | ContentLength64 | long | The length of the data that is being sent back | - | | ContentType | string | The content type of the data that's being sent back | application/json | | Headers | PodeResponseHeaders | A collection of headers that should be sent back to the client | - | diff --git a/examples/FileBrowser/FileBrowser.ps1 b/examples/FileBrowser/FileBrowser.ps1 index 54273c331..16acf814d 100644 --- a/examples/FileBrowser/FileBrowser.ps1 +++ b/examples/FileBrowser/FileBrowser.ps1 @@ -41,13 +41,16 @@ catch { throw } $directoryPath = $podePath # Start Pode server -Start-PodeServer -ConfigFile '..\Server.psd1' -ScriptBlock { +#Start-PodeServer -ConfigFile '..\Server.psd1' -ScriptBlock { +Start-PodeServer -ScriptBlock { Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http -Default New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + # Set-PodeServerSetting -Compression -Enable -Encoding 'gzip' + #Set-PodeServerSetting -Cache -Enable # setup basic auth (base64> username:password in header) New-PodeAuthScheme -Basic -Realm 'Pode Static Page' | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { param($username, $password) @@ -65,7 +68,7 @@ Start-PodeServer -ConfigFile '..\Server.psd1' -ScriptBlock { return @{ Message = 'Invalid details supplied' } } - Add-PodeRoute -Method Get -Path '/LICENSE.txt' -ScriptBlock { + Add-PodeRoute -Method Get -Path '*/LICENSE.txt' -ScriptBlock { $value = @' Don't kid me. Nobody will believe that you want to read this legal nonsense. I want to be kind; this is a summary of the content: @@ -76,9 +79,18 @@ Nothing to report :D } Add-PodeStaticRouteGroup -FileBrowser -Routes { - Add-PodeStaticRoute -Path '/' -Source $using:directoryPath - Add-PodeStaticRoute -Path '/download' -Source $using:directoryPath -DownloadOnly + Add-PodeStaticRoute -Path '/standard' -Source $using:directoryPath + Add-PodeStaticRoute -Path '/download' -Source $using:directoryPath -DownloadOnly -PassThru | Add-PodeRouteCompression -Enable -Encoding gzip Add-PodeStaticRoute -Path '/nodownload' -Source $using:directoryPath + Add-PodeStaticRoute -Path '/gzip' -Source $using:directoryPath -PassThru | Add-PodeRouteCompression -Enable -Encoding gzip + Add-PodeStaticRoute -Path '/deflate' -Source $using:directoryPath -PassThru | Add-PodeRouteCompression -Enable -Encoding deflate + Add-PodeStaticRoute -Path '/cache' -Source $using:directoryPath -PassThru | Add-PodeRouteCache -Enable -MaxAge 3600 -Visibility public -ETagMode mtime -Immutable + + Add-PodeStaticRoute -Path '/compress_cache' -Source $using:directoryPath -PassThru | Add-PodeRouteCache -Enable -MaxAge 3600 -Visibility public -ETagMode mtime -Immutable -PassThru | Add-PodeRouteCompression -Enable -Encoding deflate, gzip, br + + if ($IsCoreCLR) { + Add-PodeStaticRoute -Path '/br' -Source $using:directoryPath -PassThru | Add-PodeRouteCompression -Enable -Encoding br + } Add-PodeStaticRoute -Path '/any/*/test' -Source $using:directoryPath Add-PodeStaticRoute -Path '/auth' -Source $using:directoryPath -Authentication 'Validate' } @@ -87,4 +99,75 @@ Nothing to report :D Add-PodeRoute -Method Get -Path '/attachment/*/test' -ScriptBlock { Set-PodeResponseAttachment -Path 'ruler.png' } + + Add-PodeRoute -Method Get -Path '/encoding/transfer' -ScriptBlock { + write-podehost $webEvent -explode -ShowType -label 'Add-PodeRoute Response' + $string = Get-Content -Path $using:directoryPath/pode.build.ps1 -raw + $data = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($string)) + # write-podetextresponse -Value "This is a response with transfer encoding. The Accept-Encoding header was: $($WebEvent.AcceptEncoding)" + Write-PodeJsonResponse -Value @{ Data = $data } + } -PassThru | Add-PodeRouteCompression -Enable -Encoding gzip + + + Add-PodeRoute -Method Post -Path '/encoding/transfer' -ScriptBlock { + Write-PodeJsonResponse -Value @{ Username = $WebEvent.Data.username } + } + Add-PodeRoute -Method Post -Path '/encoding/transfer-forced-type' -TransferEncoding 'gzip' -ScriptBlock { + Write-PodeJsonResponse -Value @{ Username = $WebEvent.Data.username } + } + + Add-PodeRoute -Method Get -Path '/' -ScriptBlock { + $str = @' + + + + + Pode Static-Route Index + + + +

Route Links

+ + + +'@ + Write-PodeHtmlResponse -Value $str -StatusCode 200 + } + } diff --git a/src/Listener/PodeCompressionType.cs b/src/Listener/PodeCompressionType.cs index 459470bc8..11ad83fd1 100644 --- a/src/Listener/PodeCompressionType.cs +++ b/src/Listener/PodeCompressionType.cs @@ -2,7 +2,9 @@ namespace Pode { public enum PodeCompressionType { - Gzip, - Deflate + none, + br, + gzip, + deflate } } \ No newline at end of file diff --git a/src/Listener/PodeHelpers.cs b/src/Listener/PodeHelpers.cs index 50adc84c6..9270e2b47 100644 --- a/src/Listener/PodeHelpers.cs +++ b/src/Listener/PodeHelpers.cs @@ -298,12 +298,15 @@ public static Stream CompressStream(Stream stream, PodeCompressionType type, Com switch (type) { - case PodeCompressionType.Gzip: + case PodeCompressionType.gzip: return new GZipStream(stream, mode, leaveOpen); - case PodeCompressionType.Deflate: + case PodeCompressionType.deflate: return new DeflateStream(stream, mode, leaveOpen); - +#if NETCOREAPP2_1_OR_GREATER + case PodeCompressionType.br: + return new BrotliStream(stream, mode, leaveOpen); +#endif default: return stream; } diff --git a/src/Listener/PodeMimeTypes.cs b/src/Listener/PodeMimeTypes.cs new file mode 100644 index 000000000..400b33603 --- /dev/null +++ b/src/Listener/PodeMimeTypes.cs @@ -0,0 +1,1453 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Pode +{ + /// + /// Global MIME-type registry keyed by file-name extension (".json", ".png", …). + /// + public static class PodeMimeTypes + { + /// + /// Core store: ordinary (non-thread-safe) dictionary, case-insensitive keys. + /// + private static readonly Dictionary _map = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /* ────────────────────────────── + * Static constructor: seed defaults + * ────────────────────────────── */ + static PodeMimeTypes() + { + #region MimeType + _map[".ez"] = "application/andrew-inset"; + _map[".appinstaller"] = "application/appinstaller"; + _map[".aw"] = "application/applixware"; + _map[".appx"] = "application/appx"; + _map[".appxbundle"] = "application/appxbundle"; + _map[".atom"] = "application/atom+xml"; + _map[".atomcat"] = "application/atomcat+xml"; + _map[".atomdeleted"] = "application/atomdeleted+xml"; + _map[".atomsvc"] = "application/atomsvc+xml"; + _map[".dwd"] = "application/atsc-dwd+xml"; + _map[".held"] = "application/atsc-held+xml"; + _map[".rsat"] = "application/atsc-rsat+xml"; + _map[".aml"] = "application/automationml-aml+xml"; + _map[".amlx"] = "application/automationml-amlx+zip"; + _map[".bdoc"] = "application/bdoc"; + _map[".xcs"] = "application/calendar+xml"; + _map[".ccxml"] = "application/ccxml+xml"; + _map[".cdfx"] = "application/cdfx+xml"; + _map[".cdmia"] = "application/cdmi-capability"; + _map[".cdmic"] = "application/cdmi-container"; + _map[".cdmid"] = "application/cdmi-domain"; + _map[".cdmio"] = "application/cdmi-object"; + _map[".cdmiq"] = "application/cdmi-queue"; + _map[".cpl"] = "application/cpl+xml"; + _map[".cu"] = "application/cu-seeme"; + _map[".cwl"] = "application/cwl"; + _map[".mpd"] = "application/dash+xml"; + _map[".mpp"] = "application/vnd.ms-project"; + _map[".davmount"] = "application/davmount+xml"; + _map[".dcm"] = "application/dicom"; + _map[".dbk"] = "application/docbook+xml"; + _map[".dssc"] = "application/dssc+der"; + _map[".xdssc"] = "application/dssc+xml"; + _map[".ecma"] = "application/ecmascript"; + _map[".emma"] = "application/emma+xml"; + _map[".emotionml"] = "application/emotionml+xml"; + _map[".epub"] = "application/epub+zip"; + _map[".exi"] = "application/exi"; + _map[".exp"] = "application/express"; + _map[".fdf"] = "application/vnd.fdf"; + _map[".fdt"] = "application/fdt+xml"; + _map[".pfr"] = "application/font-tdpfr"; + _map[".geojson"] = "application/geo+json"; + _map[".gml"] = "application/gml+xml"; + _map[".gpx"] = "application/gpx+xml"; + _map[".gxf"] = "application/gxf"; + _map[".gz"] = "application/gzip"; + _map[".hjson"] = "application/hjson"; + _map[".stk"] = "application/hyperstudio"; + _map[".ink"] = "application/inkml+xml"; + _map[".inkml"] = "application/inkml+xml"; + _map[".ipfix"] = "application/ipfix"; + _map[".its"] = "application/its+xml"; + _map[".jar"] = "application/java-archive"; + _map[".war"] = "application/java-archive"; + _map[".ear"] = "application/java-archive"; + _map[".ser"] = "application/java-serialized-object"; + _map[".class"] = "application/java-vm"; + _map[".js"] = "application/javascript"; + _map[".json"] = "application/json"; + _map[".map"] = "application/json"; + _map[".json5"] = "application/json5"; + _map[".jsonml"] = "application/jsonml+json"; + _map[".jsonld"] = "application/ld+json"; + _map[".lgr"] = "application/lgr+xml"; + _map[".lostxml"] = "application/lost+xml"; + _map[".hqx"] = "application/mac-binhex40"; + _map[".cpt"] = "application/mac-compactpro"; + _map[".mads"] = "application/mads+xml"; + _map[".webmanifest"] = "application/manifest+json"; + _map[".mrc"] = "application/marc"; + _map[".mrcx"] = "application/marcxml+xml"; + _map[".ma"] = "application/mathematica"; + _map[".nb"] = "application/mathematica"; + _map[".mb"] = "application/mathematica"; + _map[".mathml"] = "application/mathml+xml"; + _map[".mbox"] = "application/mbox"; + _map[".mpf"] = "application/media-policy-dataset+xml"; + _map[".mscml"] = "application/mediaservercontrol+xml"; + _map[".metalink"] = "application/metalink+xml"; + _map[".meta4"] = "application/metalink4+xml"; + _map[".mets"] = "application/mets+xml"; + _map[".maei"] = "application/mmt-aei+xml"; + _map[".musd"] = "application/mmt-usd+xml"; + _map[".mods"] = "application/mods+xml"; + _map[".m21"] = "application/mp21"; + _map[".mp21"] = "application/mp21"; + _map[".mp4"] = "video/mp4"; + _map[".mpg4"] = "video/mp4"; + _map[".mp4s"] = "application/mp4"; + _map[".m4p"] = "application/mp4"; + _map[".msix"] = "application/msix"; + _map[".msixbundle"] = "application/msixbundle"; + _map[".doc"] = "application/msword"; + _map[".dot"] = "application/msword"; + _map[".mxf"] = "application/mxf"; + _map[".nq"] = "application/n-quads"; + _map[".nt"] = "application/n-triples"; + _map[".cjs"] = "application/node"; + _map[".oda"] = "application/oda"; + _map[".opf"] = "application/oebps-package+xml"; + _map[".ogx"] = "application/ogg"; + _map[".omdoc"] = "application/omdoc+xml"; + _map[".onetoc"] = "application/onenote"; + _map[".onetoc2"] = "application/onenote"; + _map[".onetmp"] = "application/onenote"; + _map[".onepkg"] = "application/onenote"; + _map[".one"] = "application/onenote"; + _map[".onea"] = "application/onenote"; + _map[".oxps"] = "application/oxps"; + _map[".relo"] = "application/p2p-overlay+xml"; + _map[".xer"] = "application/patch-ops-error+xml"; + _map[".pdf"] = "application/pdf"; + _map[".pgp"] = "application/pgp-encrypted"; + _map[".asc"] = "application/pgp-keys"; + _map[".sig"] = "application/pgp-signature"; + _map[".prf"] = "application/pics-rules"; + _map[".p10"] = "application/pkcs10"; + _map[".p7m"] = "application/pkcs7-mime"; + _map[".p7c"] = "application/pkcs7-mime"; + _map[".p7s"] = "application/pkcs7-signature"; + _map[".p8"] = "application/pkcs8"; + _map[".ac"] = "application/pkix-attr-cert"; + _map[".cer"] = "application/pkix-cert"; + _map[".crl"] = "application/pkix-crl"; + _map[".pkipath"] = "application/pkix-pkipath"; + _map[".pki"] = "application/pkixcmp"; + _map[".pls"] = "application/pls+xml"; + _map[".ai"] = "application/postscript"; + _map[".eps"] = "application/postscript"; + _map[".ps"] = "application/postscript"; + _map[".provx"] = "application/provenance+xml"; + _map[".cww"] = "application/prs.cww"; + _map[".xsf"] = "application/prs.xsf+xml"; + _map[".pskcxml"] = "application/pskc+xml"; + _map[".raml"] = "application/raml+yaml"; + _map[".rdf"] = "application/rdf+xml"; + _map[".owl"] = "application/rdf+xml"; + _map[".rif"] = "application/reginfo+xml"; + _map[".rnc"] = "application/relax-ng-compact-syntax"; + _map[".rl"] = "application/resource-lists+xml"; + _map[".rld"] = "application/resource-lists-diff+xml"; + _map[".rs"] = "application/rls-services+xml"; + _map[".rapd"] = "application/route-apd+xml"; + _map[".sls"] = "application/route-s-tsid+xml"; + _map[".rusd"] = "application/route-usd+xml"; + _map[".gbr"] = "application/rpki-ghostbusters"; + _map[".mft"] = "application/rpki-manifest"; + _map[".roa"] = "application/rpki-roa"; + _map[".rsd"] = "application/rsd+xml"; + _map[".rss"] = "application/rss+xml"; + _map[".rtf"] = "application/rtf"; + _map[".sbml"] = "application/sbml+xml"; + _map[".scq"] = "application/scvp-cv-request"; + _map[".scs"] = "application/scvp-cv-response"; + _map[".spq"] = "application/scvp-vp-request"; + _map[".spp"] = "application/scvp-vp-response"; + _map[".sdp"] = "application/sdp"; + _map[".senmlx"] = "application/senml+xml"; + _map[".sensmlx"] = "application/sensml+xml"; + _map[".setpay"] = "application/set-payment-initiation"; + _map[".setreg"] = "application/set-registration-initiation"; + _map[".shf"] = "application/shf+xml"; + _map[".siv"] = "application/sieve"; + _map[".sieve"] = "application/sieve"; + _map[".smi"] = "application/smil+xml"; + _map[".smil"] = "application/smil+xml"; + _map[".rq"] = "application/sparql-query"; + _map[".srx"] = "application/sparql-results+xml"; + _map[".gram"] = "application/srgs"; + _map[".grxml"] = "application/srgs+xml"; + _map[".sru"] = "application/sru+xml"; + _map[".ssdl"] = "application/ssdl+xml"; + _map[".ssml"] = "application/ssml+xml"; + _map[".swidtag"] = "application/swid+xml"; + _map[".tei"] = "application/tei+xml"; + _map[".teicorpus"] = "application/tei+xml"; + _map[".tfi"] = "application/thraud+xml"; + _map[".tsd"] = "application/timestamped-data"; + _map[".trig"] = "application/trig"; + _map[".ttml"] = "application/ttml+xml"; + _map[".ubj"] = "application/ubjson"; + _map[".rsheet"] = "application/urc-ressheet+xml"; + _map[".td"] = "application/urc-targetdesc+xml"; + _map[".1km"] = "application/vnd.1000minds.decision-model+xml"; + _map[".plb"] = "application/vnd.3gpp.pic-bw-large"; + _map[".psb"] = "application/vnd.3gpp.pic-bw-small"; + _map[".pvb"] = "application/vnd.3gpp.pic-bw-var"; + _map[".tcap"] = "application/vnd.3gpp2.tcap"; + _map[".pwn"] = "application/vnd.3m.post-it-notes"; + _map[".aso"] = "application/vnd.accpac.simply.aso"; + _map[".imp"] = "application/vnd.accpac.simply.imp"; + _map[".acu"] = "application/vnd.acucobol"; + _map[".atc"] = "application/vnd.acucorp"; + _map[".acutc"] = "application/vnd.acucorp"; + _map[".air"] = "application/vnd.adobe.air-application-installer-package+zip"; + _map[".fcdt"] = "application/vnd.adobe.formscentral.fcdt"; + _map[".fxp"] = "application/vnd.adobe.fxp"; + _map[".fxpl"] = "application/vnd.adobe.fxp"; + _map[".xdp"] = "application/vnd.adobe.xdp+xml"; + _map[".xfdf"] = "application/vnd.adobe.xfdf"; + _map[".age"] = "application/vnd.age"; + _map[".ahead"] = "application/vnd.ahead.space"; + _map[".azf"] = "application/vnd.airzip.filesecure.azf"; + _map[".azs"] = "application/vnd.airzip.filesecure.azs"; + _map[".azw"] = "application/vnd.amazon.ebook"; + _map[".acc"] = "application/vnd.americandynamics.acc"; + _map[".ami"] = "application/vnd.amiga.ami"; + _map[".apk"] = "application/vnd.android.package-archive"; + _map[".cii"] = "application/vnd.anser-web-certificate-issue-initiation"; + _map[".fti"] = "application/vnd.anser-web-funds-transfer-initiation"; + _map[".atx"] = "application/vnd.antix.game-component"; + _map[".mpkg"] = "application/vnd.apple.installer+xml"; + _map[".key"] = "application/vnd.apple.keynote"; + _map[".m3u8"] = "application/vnd.apple.mpegurl"; + _map[".numbers"] = "application/vnd.apple.numbers"; + _map[".pages"] = "application/vnd.apple.pages"; + _map[".pkpass"] = "application/vnd.apple.pkpass"; + _map[".swi"] = "application/vnd.aristanetworks.swi"; + _map[".iota"] = "application/vnd.astraea-software.iota"; + _map[".aep"] = "application/vnd.audiograph"; + _map[".fbx"] = "application/vnd.autodesk.fbx"; + _map[".bmml"] = "application/vnd.balsamiq.bmml+xml"; + _map[".mpm"] = "application/vnd.blueice.multipass"; + _map[".bmi"] = "application/vnd.bmi"; + _map[".rep"] = "application/vnd.businessobjects"; + _map[".cdxml"] = "application/vnd.chemdraw+xml"; + _map[".mmd"] = "application/vnd.chipnuts.karaoke-mmd"; + _map[".cdy"] = "application/vnd.cinderella"; + _map[".csl"] = "application/vnd.citationstyles.style+xml"; + _map[".cla"] = "application/vnd.claymore"; + _map[".rp9"] = "application/vnd.cloanto.rp9"; + _map[".c4g"] = "application/vnd.clonk.c4group"; + _map[".c4d"] = "application/vnd.clonk.c4group"; + _map[".c4f"] = "application/vnd.clonk.c4group"; + _map[".c4p"] = "application/vnd.clonk.c4group"; + _map[".c4u"] = "application/vnd.clonk.c4group"; + _map[".c11amc"] = "application/vnd.cluetrust.cartomobile-config"; + _map[".c11amz"] = "application/vnd.cluetrust.cartomobile-config-pkg"; + _map[".csp"] = "application/vnd.commonspace"; + _map[".cdbcmsg"] = "application/vnd.contact.cmsg"; + _map[".cmc"] = "application/vnd.cosmocaller"; + _map[".clkx"] = "application/vnd.crick.clicker"; + _map[".clkk"] = "application/vnd.crick.clicker.keyboard"; + _map[".clkp"] = "application/vnd.crick.clicker.palette"; + _map[".clkt"] = "application/vnd.crick.clicker.template"; + _map[".clkw"] = "application/vnd.crick.clicker.wordbank"; + _map[".wbs"] = "application/vnd.criticaltools.wbs+xml"; + _map[".pml"] = "application/vnd.ctc-posml"; + _map[".ppd"] = "application/vnd.cups-ppd"; + _map[".car"] = "application/vnd.curl.car"; + _map[".pcurl"] = "application/vnd.curl.pcurl"; + _map[".dart"] = "application/vnd.dart"; + _map[".rdz"] = "application/vnd.data-vision.rdz"; + _map[".dbf"] = "application/vnd.dbf"; + _map[".dcmp"] = "application/vnd.dcmp+xml"; + _map[".uvf"] = "application/vnd.dece.data"; + _map[".uvvf"] = "application/vnd.dece.data"; + _map[".uvd"] = "application/vnd.dece.data"; + _map[".uvvd"] = "application/vnd.dece.data"; + _map[".uvt"] = "application/vnd.dece.ttml+xml"; + _map[".uvvt"] = "application/vnd.dece.ttml+xml"; + _map[".uvx"] = "application/vnd.dece.unspecified"; + _map[".uvvx"] = "application/vnd.dece.unspecified"; + _map[".uvz"] = "application/vnd.dece.zip"; + _map[".uvvz"] = "application/vnd.dece.zip"; + _map[".fe_launch"] = "application/vnd.denovo.fcselayout-link"; + _map[".dna"] = "application/vnd.dna"; + _map[".mlp"] = "application/vnd.dolby.mlp"; + _map[".dpg"] = "application/vnd.dpgraph"; + _map[".dfac"] = "application/vnd.dreamfactory"; + _map[".kpxx"] = "application/vnd.ds-keypoint"; + _map[".ait"] = "application/vnd.dvb.ait"; + _map[".svc"] = "application/vnd.dvb.service"; + _map[".geo"] = "application/vnd.dynageo"; + _map[".mag"] = "application/vnd.ecowin.chart"; + _map[".nml"] = "application/vnd.enliven"; + _map[".esf"] = "application/vnd.epson.esf"; + _map[".msf"] = "application/vnd.epson.msf"; + _map[".qam"] = "application/vnd.epson.quickanime"; + _map[".slt"] = "application/vnd.epson.salt"; + _map[".ssf"] = "application/vnd.epson.ssf"; + _map[".es3"] = "application/vnd.eszigno3+xml"; + _map[".et3"] = "application/vnd.eszigno3+xml"; + _map[".ez2"] = "application/vnd.ezpix-album"; + _map[".ez3"] = "application/vnd.ezpix-package"; + _map[".mseed"] = "application/vnd.fdsn.mseed"; + _map[".seed"] = "application/vnd.fdsn.seed"; + _map[".dataless"] = "application/vnd.fdsn.seed"; + _map[".gph"] = "application/vnd.flographit"; + _map[".ftc"] = "application/vnd.fluxtime.clip"; + _map[".fm"] = "application/vnd.framemaker"; + _map[".frame"] = "application/vnd.framemaker"; + _map[".maker"] = "application/vnd.framemaker"; + _map[".book"] = "application/vnd.framemaker"; + _map[".fnc"] = "application/vnd.frogans.fnc"; + _map[".ltf"] = "application/vnd.frogans.ltf"; + _map[".fsc"] = "application/vnd.fsc.weblaunch"; + _map[".oas"] = "application/vnd.fujitsu.oasys"; + _map[".oa2"] = "application/vnd.fujitsu.oasys2"; + _map[".oa3"] = "application/vnd.fujitsu.oasys3"; + _map[".fg5"] = "application/vnd.fujitsu.oasysgp"; + _map[".bh2"] = "application/vnd.fujitsu.oasysprs"; + _map[".ddd"] = "application/vnd.fujixerox.ddd"; + _map[".xdw"] = "application/vnd.fujixerox.docuworks"; + _map[".xbd"] = "application/vnd.fujixerox.docuworks.binder"; + _map[".fzs"] = "application/vnd.fuzzysheet"; + _map[".txd"] = "application/vnd.genomatix.tuxedo"; + _map[".ggb"] = "application/vnd.geogebra.file"; + _map[".ggs"] = "application/vnd.geogebra.slides"; + _map[".ggt"] = "application/vnd.geogebra.tool"; + _map[".gex"] = "application/vnd.geometry-explorer"; + _map[".gre"] = "application/vnd.geometry-explorer"; + _map[".gxt"] = "application/vnd.geonext"; + _map[".g2w"] = "application/vnd.geoplan"; + _map[".g3w"] = "application/vnd.geospace"; + _map[".gmx"] = "application/vnd.gmx"; + _map[".gdoc"] = "application/vnd.google-apps.document"; + _map[".gdraw"] = "application/vnd.google-apps.drawing"; + _map[".gform"] = "application/vnd.google-apps.form"; + _map[".gjam"] = "application/vnd.google-apps.jam"; + _map[".gmap"] = "application/vnd.google-apps.map"; + _map[".gslides"] = "application/vnd.google-apps.presentation"; + _map[".gscript"] = "application/vnd.google-apps.script"; + _map[".gsite"] = "application/vnd.google-apps.site"; + _map[".gsheet"] = "application/vnd.google-apps.spreadsheet"; + _map[".kml"] = "application/vnd.google-earth.kml+xml"; + _map[".kmz"] = "application/vnd.google-earth.kmz"; + _map[".xdcf"] = "application/vnd.gov.sk.xmldatacontainer+xml"; + _map[".gqf"] = "application/vnd.grafeq"; + _map[".gqs"] = "application/vnd.grafeq"; + _map[".gac"] = "application/vnd.groove-account"; + _map[".ghf"] = "application/vnd.groove-help"; + _map[".gim"] = "application/vnd.groove-identity-message"; + _map[".grv"] = "application/vnd.groove-injector"; + _map[".gtm"] = "application/vnd.groove-tool-message"; + _map[".tpl"] = "application/vnd.groove-tool-template"; + _map[".vcg"] = "application/vnd.groove-vcard"; + _map[".hal"] = "application/vnd.hal+xml"; + _map[".zmm"] = "application/vnd.handheld-entertainment+xml"; + _map[".hbci"] = "application/vnd.hbci"; + _map[".les"] = "application/vnd.hhe.lesson-player"; + _map[".hpgl"] = "application/vnd.hp-hpgl"; + _map[".hpid"] = "application/vnd.hp-hpid"; + _map[".hps"] = "application/vnd.hp-hps"; + _map[".jlt"] = "application/vnd.hp-jlyt"; + _map[".pcl"] = "application/vnd.hp-pcl"; + _map[".pclxl"] = "application/vnd.hp-pclxl"; + _map[".sfd-hdstx"] = "application/vnd.hydrostatix.sof-data"; + _map[".mpy"] = "application/vnd.ibm.minipay"; + _map[".afp"] = "application/vnd.ibm.modcap"; + _map[".listafp"] = "application/vnd.ibm.modcap"; + _map[".list3820"] = "application/vnd.ibm.modcap"; + _map[".irm"] = "application/vnd.ibm.rights-management"; + _map[".sc"] = "application/vnd.ibm.secure-container"; + _map[".icc"] = "application/vnd.iccprofile"; + _map[".icm"] = "application/vnd.iccprofile"; + _map[".igl"] = "application/vnd.igloader"; + _map[".ivp"] = "application/vnd.immervision-ivp"; + _map[".ivu"] = "application/vnd.immervision-ivu"; + _map[".igm"] = "application/vnd.insors.igm"; + _map[".xpw"] = "application/vnd.intercon.formnet"; + _map[".xpx"] = "application/vnd.intercon.formnet"; + _map[".i2g"] = "application/vnd.intergeo"; + _map[".qbo"] = "application/vnd.intu.qbo"; + _map[".qfx"] = "application/vnd.intu.qfx"; + _map[".rcprofile"] = "application/vnd.ipunplugged.rcprofile"; + _map[".irp"] = "application/vnd.irepository.package+xml"; + _map[".xpr"] = "application/vnd.is-xpr"; + _map[".fcs"] = "application/vnd.isac.fcs"; + _map[".jam"] = "application/vnd.jam"; + _map[".rms"] = "application/vnd.jcp.javame.midlet-rms"; + _map[".jisp"] = "application/vnd.jisp"; + _map[".joda"] = "application/vnd.joost.joda-archive"; + _map[".ktz"] = "application/vnd.kahootz"; + _map[".ktr"] = "application/vnd.kahootz"; + _map[".karbon"] = "application/vnd.kde.karbon"; + _map[".chrt"] = "application/vnd.kde.kchart"; + _map[".kfo"] = "application/vnd.kde.kformula"; + _map[".flw"] = "application/vnd.kde.kivio"; + _map[".kon"] = "application/vnd.kde.kontour"; + _map[".kpr"] = "application/vnd.kde.kpresenter"; + _map[".kpt"] = "application/vnd.kde.kpresenter"; + _map[".ksp"] = "application/vnd.kde.kspread"; + _map[".kwd"] = "application/vnd.kde.kword"; + _map[".kwt"] = "application/vnd.kde.kword"; + _map[".htke"] = "application/vnd.kenameaapp"; + _map[".kia"] = "application/vnd.kidspiration"; + _map[".kne"] = "application/vnd.kinar"; + _map[".knp"] = "application/vnd.kinar"; + _map[".skp"] = "application/vnd.koan"; + _map[".skd"] = "application/vnd.koan"; + _map[".skt"] = "application/vnd.koan"; + _map[".skm"] = "application/vnd.koan"; + _map[".sse"] = "application/vnd.kodak-descriptor"; + _map[".lasxml"] = "application/vnd.las.las+xml"; + _map[".lbd"] = "application/vnd.llamagraphics.life-balance.desktop"; + _map[".lbe"] = "application/vnd.llamagraphics.life-balance.exchange+xml"; + _map[".123"] = "application/vnd.lotus-1-2-3"; + _map[".apr"] = "application/vnd.lotus-approach"; + _map[".pre"] = "application/vnd.lotus-freelance"; + _map[".nsf"] = "application/vnd.lotus-notes"; + _map[".org"] = "application/vnd.lotus-organizer"; + _map[".scm"] = "application/vnd.lotus-screencam"; + _map[".lwp"] = "application/vnd.lotus-wordpro"; + _map[".portpkg"] = "application/vnd.macports.portpkg"; + _map[".mvt"] = "application/vnd.mapbox-vector-tile"; + _map[".mcd"] = "application/vnd.mcd"; + _map[".mc1"] = "application/vnd.medcalcdata"; + _map[".cdkey"] = "application/vnd.mediastation.cdkey"; + _map[".mwf"] = "application/vnd.mfer"; + _map[".mfm"] = "application/vnd.mfmp"; + _map[".flo"] = "application/vnd.micrografx.flo"; + _map[".igx"] = "application/vnd.micrografx.igx"; + _map[".mif"] = "application/vnd.mif"; + _map[".daf"] = "application/vnd.mobius.daf"; + _map[".dis"] = "application/vnd.mobius.dis"; + _map[".mbk"] = "application/vnd.mobius.mbk"; + _map[".mqy"] = "application/vnd.mobius.mqy"; + _map[".msl"] = "application/vnd.mobius.msl"; + _map[".plc"] = "application/vnd.mobius.plc"; + _map[".txf"] = "application/vnd.mobius.txf"; + _map[".mpn"] = "application/vnd.mophun.application"; + _map[".mpc"] = "application/vnd.mophun.certificate"; + _map[".xul"] = "application/vnd.mozilla.xul+xml"; + _map[".cil"] = "application/vnd.ms-artgalry"; + _map[".cab"] = "application/vnd.ms-cab-compressed"; + _map[".xls"] = "application/vnd.ms-excel"; + _map[".xlm"] = "application/vnd.ms-excel"; + _map[".xla"] = "application/vnd.ms-excel"; + _map[".xlc"] = "application/vnd.ms-excel"; + _map[".xlt"] = "application/vnd.ms-excel"; + _map[".xlw"] = "application/vnd.ms-excel"; + _map[".xlam"] = "application/vnd.ms-excel.addin.macroenabled.12"; + _map[".xlsb"] = "application/vnd.ms-excel.sheet.binary.macroenabled.12"; + _map[".xlsm"] = "application/vnd.ms-excel.sheet.macroenabled.12"; + _map[".xltm"] = "application/vnd.ms-excel.template.macroenabled.12"; + _map[".eot"] = "application/vnd.ms-fontobject"; + _map[".chm"] = "application/vnd.ms-htmlhelp"; + _map[".ims"] = "application/vnd.ms-ims"; + _map[".lrm"] = "application/vnd.ms-lrm"; + _map[".thmx"] = "application/vnd.ms-officetheme"; + _map[".msg"] = "application/vnd.ms-outlook"; + _map[".cat"] = "application/vnd.ms-pki.seccat"; + _map[".stl"] = "application/vnd.ms-pki.stl"; + _map[".ppt"] = "application/vnd.ms-powerpoint"; + _map[".pps"] = "application/vnd.ms-powerpoint"; + _map[".pot"] = "application/vnd.ms-powerpoint"; + _map[".ppam"] = "application/vnd.ms-powerpoint.addin.macroenabled.12"; + _map[".pptm"] = "application/vnd.ms-powerpoint.presentation.macroenabled.12"; + _map[".sldm"] = "application/vnd.ms-powerpoint.slide.macroenabled.12"; + _map[".ppsm"] = "application/vnd.ms-powerpoint.slideshow.macroenabled.12"; + _map[".potm"] = "application/vnd.ms-powerpoint.template.macroenabled.12"; + _map[".mpt"] = "application/vnd.ms-project"; + _map[".vdx"] = "application/vnd.ms-visio.viewer"; + _map[".docm"] = "application/vnd.ms-word.document.macroenabled.12"; + _map[".dotm"] = "application/vnd.ms-word.template.macroenabled.12"; + _map[".wps"] = "application/vnd.ms-works"; + _map[".wks"] = "application/vnd.ms-works"; + _map[".wcm"] = "application/vnd.ms-works"; + _map[".wdb"] = "application/vnd.ms-works"; + _map[".wpl"] = "application/vnd.ms-wpl"; + _map[".xps"] = "application/vnd.ms-xpsdocument"; + _map[".mseq"] = "application/vnd.mseq"; + _map[".mus"] = "application/vnd.musician"; + _map[".msty"] = "application/vnd.muvee.style"; + _map[".taglet"] = "application/vnd.mynfc"; + _map[".bdo"] = "application/vnd.nato.bindingdataobject+xml"; + _map[".nlu"] = "application/vnd.neurolanguage.nlu"; + _map[".ntf"] = "application/vnd.nitf"; + _map[".nitf"] = "application/vnd.nitf"; + _map[".nnd"] = "application/vnd.noblenet-directory"; + _map[".nns"] = "application/vnd.noblenet-sealer"; + _map[".nnw"] = "application/vnd.noblenet-web"; + _map[".ngdat"] = "application/vnd.nokia.n-gage.data"; + _map[".n-gage"] = "application/vnd.nokia.n-gage.symbian.install"; + _map[".rpst"] = "application/vnd.nokia.radio-preset"; + _map[".rpss"] = "application/vnd.nokia.radio-presets"; + _map[".edm"] = "application/vnd.novadigm.edm"; + _map[".edx"] = "application/vnd.novadigm.edx"; + _map[".ext"] = "application/vnd.novadigm.ext"; + _map[".odc"] = "application/vnd.oasis.opendocument.chart"; + _map[".otc"] = "application/vnd.oasis.opendocument.chart-template"; + _map[".odb"] = "application/vnd.oasis.opendocument.database"; + _map[".odf"] = "application/vnd.oasis.opendocument.formula"; + _map[".odft"] = "application/vnd.oasis.opendocument.formula-template"; + _map[".odg"] = "application/vnd.oasis.opendocument.graphics"; + _map[".otg"] = "application/vnd.oasis.opendocument.graphics-template"; + _map[".odi"] = "application/vnd.oasis.opendocument.image"; + _map[".oti"] = "application/vnd.oasis.opendocument.image-template"; + _map[".odp"] = "application/vnd.oasis.opendocument.presentation"; + _map[".otp"] = "application/vnd.oasis.opendocument.presentation-template"; + _map[".ods"] = "application/vnd.oasis.opendocument.spreadsheet"; + _map[".ots"] = "application/vnd.oasis.opendocument.spreadsheet-template"; + _map[".odt"] = "application/vnd.oasis.opendocument.text"; + _map[".odm"] = "application/vnd.oasis.opendocument.text-master"; + _map[".ott"] = "application/vnd.oasis.opendocument.text-template"; + _map[".oth"] = "application/vnd.oasis.opendocument.text-web"; + _map[".xo"] = "application/vnd.olpc-sugar"; + _map[".dd2"] = "application/vnd.oma.dd2+xml"; + _map[".obgx"] = "application/vnd.openblox.game+xml"; + _map[".oxt"] = "application/vnd.openofficeorg.extension"; + _map[".osm"] = "application/vnd.openstreetmap.data+xml"; + _map[".pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + _map[".sldx"] = "application/vnd.openxmlformats-officedocument.presentationml.slide"; + _map[".ppsx"] = "application/vnd.openxmlformats-officedocument.presentationml.slideshow"; + _map[".potx"] = "application/vnd.openxmlformats-officedocument.presentationml.template"; + _map[".xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + _map[".xltx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.template"; + _map[".docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + _map[".dotx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.template"; + _map[".mgp"] = "application/vnd.osgeo.mapguide.package"; + _map[".dp"] = "application/vnd.osgi.dp"; + _map[".esa"] = "application/vnd.osgi.subsystem"; + _map[".pdb"] = "application/vnd.palm"; + _map[".pqa"] = "application/vnd.palm"; + _map[".oprc"] = "application/vnd.palm"; + _map[".paw"] = "application/vnd.pawaafile"; + _map[".str"] = "application/vnd.pg.format"; + _map[".ei6"] = "application/vnd.pg.osasli"; + _map[".efif"] = "application/vnd.picsel"; + _map[".wg"] = "application/vnd.pmi.widget"; + _map[".plf"] = "application/vnd.pocketlearn"; + _map[".pbd"] = "application/vnd.powerbuilder6"; + _map[".box"] = "application/vnd.previewsystems.box"; + _map[".brushset"] = "application/vnd.procrate.brushset"; + _map[".brush"] = "application/vnd.procreate.brush"; + _map[".drm"] = "application/vnd.procreate.dream"; + _map[".mgz"] = "application/vnd.proteus.magazine"; + _map[".qps"] = "application/vnd.publishare-delta-tree"; + _map[".ptid"] = "application/vnd.pvi.ptid1"; + _map[".xhtm"] = "application/vnd.pwg-xhtml-print+xml"; + _map[".qxd"] = "application/vnd.quark.quarkxpress"; + _map[".qxt"] = "application/vnd.quark.quarkxpress"; + _map[".qwd"] = "application/vnd.quark.quarkxpress"; + _map[".qwt"] = "application/vnd.quark.quarkxpress"; + _map[".qxl"] = "application/vnd.quark.quarkxpress"; + _map[".qxb"] = "application/vnd.quark.quarkxpress"; + _map[".rar"] = "application/vnd.rar"; + _map[".bed"] = "application/vnd.realvnc.bed"; + _map[".mxl"] = "application/vnd.recordare.musicxml"; + _map[".musicxml"] = "application/vnd.recordare.musicxml+xml"; + _map[".cryptonote"] = "application/vnd.rig.cryptonote"; + _map[".cod"] = "application/vnd.rim.cod"; + _map[".rm"] = "application/vnd.rn-realmedia"; + _map[".rmvb"] = "application/vnd.rn-realmedia-vbr"; + _map[".link66"] = "application/vnd.route66.link66+xml"; + _map[".st"] = "application/vnd.sailingtracker.track"; + _map[".see"] = "application/vnd.seemail"; + _map[".sema"] = "application/vnd.sema"; + _map[".semd"] = "application/vnd.semd"; + _map[".semf"] = "application/vnd.semf"; + _map[".ifm"] = "application/vnd.shana.informed.formdata"; + _map[".itp"] = "application/vnd.shana.informed.formtemplate"; + _map[".iif"] = "application/vnd.shana.informed.interchange"; + _map[".ipk"] = "application/vnd.shana.informed.package"; + _map[".twd"] = "application/vnd.simtech-mindmapper"; + _map[".twds"] = "application/vnd.simtech-mindmapper"; + _map[".mmf"] = "application/vnd.smaf"; + _map[".teacher"] = "application/vnd.smart.teacher"; + _map[".fo"] = "application/vnd.software602.filler.form+xml"; + _map[".sdkm"] = "application/vnd.solent.sdkm+xml"; + _map[".sdkd"] = "application/vnd.solent.sdkm+xml"; + _map[".dxp"] = "application/vnd.spotfire.dxp"; + _map[".sfs"] = "application/vnd.spotfire.sfs"; + _map[".sdc"] = "application/vnd.stardivision.calc"; + _map[".sda"] = "application/vnd.stardivision.draw"; + _map[".sdd"] = "application/vnd.stardivision.impress"; + _map[".smf"] = "application/vnd.stardivision.math"; + _map[".sdw"] = "application/vnd.stardivision.writer"; + _map[".vor"] = "application/vnd.stardivision.writer"; + _map[".sgl"] = "application/vnd.stardivision.writer-global"; + _map[".smzip"] = "application/vnd.stepmania.package"; + _map[".sm"] = "application/vnd.stepmania.stepchart"; + _map[".wadl"] = "application/vnd.sun.wadl+xml"; + _map[".sxc"] = "application/vnd.sun.xml.calc"; + _map[".stc"] = "application/vnd.sun.xml.calc.template"; + _map[".sxd"] = "application/vnd.sun.xml.draw"; + _map[".std"] = "application/vnd.sun.xml.draw.template"; + _map[".sxi"] = "application/vnd.sun.xml.impress"; + _map[".sti"] = "application/vnd.sun.xml.impress.template"; + _map[".sxm"] = "application/vnd.sun.xml.math"; + _map[".sxw"] = "application/vnd.sun.xml.writer"; + _map[".sxg"] = "application/vnd.sun.xml.writer.global"; + _map[".stw"] = "application/vnd.sun.xml.writer.template"; + _map[".sus"] = "application/vnd.sus-calendar"; + _map[".susp"] = "application/vnd.sus-calendar"; + _map[".svd"] = "application/vnd.svd"; + _map[".sis"] = "application/vnd.symbian.install"; + _map[".sisx"] = "application/vnd.symbian.install"; + _map[".xsm"] = "application/vnd.syncml+xml"; + _map[".bdm"] = "application/vnd.syncml.dm+wbxml"; + _map[".xdm"] = "application/vnd.syncml.dm+xml"; + _map[".ddf"] = "application/vnd.syncml.dmddf+xml"; + _map[".tao"] = "application/vnd.tao.intent-module-archive"; + _map[".pcap"] = "application/vnd.tcpdump.pcap"; + _map[".cap"] = "application/vnd.tcpdump.pcap"; + _map[".dmp"] = "application/vnd.tcpdump.pcap"; + _map[".tmo"] = "application/vnd.tmobile-livetv"; + _map[".tpt"] = "application/vnd.trid.tpt"; + _map[".mxs"] = "application/vnd.triscape.mxs"; + _map[".tra"] = "application/vnd.trueapp"; + _map[".ufd"] = "application/vnd.ufdl"; + _map[".ufdl"] = "application/vnd.ufdl"; + _map[".utz"] = "application/vnd.uiq.theme"; + _map[".umj"] = "application/vnd.umajin"; + _map[".unityweb"] = "application/vnd.unity"; + _map[".uoml"] = "application/vnd.uoml+xml"; + _map[".uo"] = "application/vnd.uoml+xml"; + _map[".vcx"] = "application/vnd.vcx"; + _map[".vsd"] = "application/vnd.visio"; + _map[".vst"] = "application/vnd.visio"; + _map[".vss"] = "application/vnd.visio"; + _map[".vsw"] = "application/vnd.visio"; + _map[".vsdx"] = "application/vnd.visio"; + _map[".vtx"] = "application/vnd.visio"; + _map[".vis"] = "application/vnd.visionary"; + _map[".vsf"] = "application/vnd.vsf"; + _map[".wbxml"] = "application/vnd.wap.wbxml"; + _map[".wmlc"] = "application/vnd.wap.wmlc"; + _map[".wmlsc"] = "application/vnd.wap.wmlscriptc"; + _map[".wtb"] = "application/vnd.webturbo"; + _map[".nbp"] = "application/vnd.wolfram.player"; + _map[".wpd"] = "application/vnd.wordperfect"; + _map[".wqd"] = "application/vnd.wqd"; + _map[".stf"] = "application/vnd.wt.stf"; + _map[".xar"] = "application/vnd.xara"; + _map[".xfdl"] = "application/vnd.xfdl"; + _map[".hvd"] = "application/vnd.yamaha.hv-dic"; + _map[".hvs"] = "application/vnd.yamaha.hv-script"; + _map[".hvp"] = "application/vnd.yamaha.hv-voice"; + _map[".osf"] = "application/vnd.yamaha.openscoreformat"; + _map[".osfpvg"] = "application/vnd.yamaha.openscoreformat.osfpvg+xml"; + _map[".saf"] = "application/vnd.yamaha.smaf-audio"; + _map[".spf"] = "application/vnd.yamaha.smaf-phrase"; + _map[".cmp"] = "application/vnd.yellowriver-custom-menu"; + _map[".zir"] = "application/vnd.zul"; + _map[".zirz"] = "application/vnd.zul"; + _map[".zaz"] = "application/vnd.zzazz.deck+xml"; + _map[".vxml"] = "application/voicexml+xml"; + _map[".wasm"] = "application/wasm"; + _map[".wif"] = "application/watcherinfo+xml"; + _map[".wgt"] = "application/widget"; + _map[".hlp"] = "application/winhlp"; + _map[".wsdl"] = "application/wsdl+xml"; + _map[".wspolicy"] = "application/wspolicy+xml"; + _map[".7z"] = "application/x-7z-compressed"; + _map[".abw"] = "application/x-abiword"; + _map[".ace"] = "application/x-ace-compressed"; + _map[".dmg"] = "application/x-apple-diskimage"; + _map[".arj"] = "application/x-arj"; + _map[".aab"] = "application/x-authorware-bin"; + _map[".x32"] = "application/x-authorware-bin"; + _map[".u32"] = "application/x-authorware-bin"; + _map[".vox"] = "application/x-authorware-bin"; + _map[".aam"] = "application/x-authorware-map"; + _map[".aas"] = "application/x-authorware-seg"; + _map[".bcpio"] = "application/x-bcpio"; + _map[".torrent"] = "application/x-bittorrent"; + _map[".blend"] = "application/x-blender"; + _map[".blb"] = "application/x-blorb"; + _map[".blorb"] = "application/x-blorb"; + _map[".bz"] = "application/x-bzip"; + _map[".bz2"] = "application/x-bzip2"; + _map[".boz"] = "application/x-bzip2"; + _map[".cbr"] = "application/x-cbr"; + _map[".cba"] = "application/x-cbr"; + _map[".cbt"] = "application/x-cbr"; + _map[".cbz"] = "application/x-cbr"; + _map[".cb7"] = "application/x-cbr"; + _map[".vcd"] = "application/x-cdlink"; + _map[".cfs"] = "application/x-cfs-compressed"; + _map[".chat"] = "application/x-chat"; + _map[".pgn"] = "application/x-chess-pgn"; + _map[".crx"] = "application/x-chrome-extension"; + _map[".cco"] = "application/x-cocoa"; + _map[".nsc"] = "application/x-conference"; + _map[".cpio"] = "application/x-cpio"; + _map[".csh"] = "application/x-csh"; + _map[".deb"] = "application/x-debian-package"; + _map[".udeb"] = "application/x-debian-package"; + _map[".dgc"] = "application/x-dgc-compressed"; + _map[".dir"] = "application/x-director"; + _map[".dcr"] = "application/x-director"; + _map[".dxr"] = "application/x-director"; + _map[".cst"] = "application/x-director"; + _map[".cct"] = "application/x-director"; + _map[".cxt"] = "application/x-director"; + _map[".w3d"] = "application/x-director"; + _map[".fgd"] = "application/x-director"; + _map[".swa"] = "application/x-director"; + _map[".wad"] = "application/x-doom"; + _map[".ncx"] = "application/x-dtbncx+xml"; + _map[".dtb"] = "application/x-dtbook+xml"; + _map[".res"] = "application/x-dtbresource+xml"; + _map[".dvi"] = "application/x-dvi"; + _map[".evy"] = "application/x-envoy"; + _map[".eva"] = "application/x-eva"; + _map[".bdf"] = "application/x-font-bdf"; + _map[".gsf"] = "application/x-font-ghostscript"; + _map[".psf"] = "application/x-font-linux-psf"; + _map[".pcf"] = "application/x-font-pcf"; + _map[".snf"] = "application/x-font-snf"; + _map[".pfa"] = "application/x-font-type1"; + _map[".pfb"] = "application/x-font-type1"; + _map[".pfm"] = "application/x-font-type1"; + _map[".afm"] = "application/x-font-type1"; + _map[".arc"] = "application/x-freearc"; + _map[".spl"] = "application/x-futuresplash"; + _map[".gca"] = "application/x-gca-compressed"; + _map[".ulx"] = "application/x-glulx"; + _map[".gnumeric"] = "application/x-gnumeric"; + _map[".gramps"] = "application/x-gramps-xml"; + _map[".gtar"] = "application/x-gtar"; + _map[".hdf"] = "application/x-hdf"; + _map[".php"] = "application/x-httpd-php"; + _map[".install"] = "application/x-install-instructions"; + _map[".ipynb"] = "application/x-ipynb+json"; + _map[".iso"] = "application/x-iso9660-image"; + _map[".jardiff"] = "application/x-java-archive-diff"; + _map[".jnlp"] = "application/x-java-jnlp-file"; + _map[".kdbx"] = "application/x-keepass2"; + _map[".latex"] = "application/x-latex"; + _map[".luac"] = "application/x-lua-bytecode"; + _map[".lzh"] = "application/x-lzh-compressed"; + _map[".lha"] = "application/x-lzh-compressed"; + _map[".run"] = "application/x-makeself"; + _map[".mie"] = "application/x-mie"; + _map[".prc"] = "model/prc"; + _map[".mobi"] = "application/x-mobipocket-ebook"; + _map[".application"] = "application/x-ms-application"; + _map[".lnk"] = "application/x-ms-shortcut"; + _map[".wmd"] = "application/x-ms-wmd"; + _map[".wmz"] = "application/x-ms-wmz"; + _map[".xbap"] = "application/x-ms-xbap"; + _map[".mdb"] = "application/x-msaccess"; + _map[".obd"] = "application/x-msbinder"; + _map[".crd"] = "application/x-mscardfile"; + _map[".clp"] = "application/x-msclip"; + _map[".exe"] = "application/x-msdownload"; + _map[".dll"] = "application/x-msdownload"; + _map[".com"] = "application/x-msdownload"; + _map[".msi"] = "application/x-msdownload"; + _map[".mvb"] = "application/x-msmediaview"; + _map[".m13"] = "application/x-msmediaview"; + _map[".m14"] = "application/x-msmediaview"; + _map[".wmf"] = "image/wmf"; + _map[".emf"] = "image/emf"; + _map[".emz"] = "application/x-msmetafile"; + _map[".mny"] = "application/x-msmoney"; + _map[".pub"] = "application/x-mspublisher"; + _map[".scd"] = "application/x-msschedule"; + _map[".trm"] = "application/x-msterminal"; + _map[".wri"] = "application/x-mswrite"; + _map[".nc"] = "application/x-netcdf"; + _map[".cdf"] = "application/x-netcdf"; + _map[".pac"] = "application/x-ns-proxy-autoconfig"; + _map[".nzb"] = "application/x-nzb"; + _map[".pm"] = "application/x-perl"; + _map[".p12"] = "application/x-pkcs12"; + _map[".pfx"] = "application/x-pkcs12"; + _map[".p7b"] = "application/x-pkcs7-certificates"; + _map[".spc"] = "application/x-pkcs7-certificates"; + _map[".p7r"] = "application/x-pkcs7-certreqresp"; + _map[".rpm"] = "application/x-redhat-package-manager"; + _map[".ris"] = "application/x-research-info-systems"; + _map[".sea"] = "application/x-sea"; + _map[".shar"] = "application/x-shar"; + _map[".swf"] = "application/x-shockwave-flash"; + _map[".xap"] = "application/x-silverlight-app"; + _map[".sit"] = "application/x-stuffit"; + _map[".sitx"] = "application/x-stuffitx"; + _map[".srt"] = "application/x-subrip"; + _map[".sv4cpio"] = "application/x-sv4cpio"; + _map[".sv4crc"] = "application/x-sv4crc"; + _map[".t3"] = "application/x-t3vm-image"; + _map[".gam"] = "application/x-tads"; + _map[".tar"] = "application/x-tar"; + _map[".tcl"] = "application/x-tcl"; + _map[".tk"] = "application/x-tcl"; + _map[".tex"] = "application/x-tex"; + _map[".tfm"] = "application/x-tex-tfm"; + _map[".texinfo"] = "application/x-texinfo"; + _map[".texi"] = "application/x-texinfo"; + _map[".obj"] = "application/x-tgif"; + _map[".ustar"] = "application/x-ustar"; + _map[".hdd"] = "application/x-virtualbox-hdd"; + _map[".ova"] = "application/x-virtualbox-ova"; + _map[".ovf"] = "application/x-virtualbox-ovf"; + _map[".vbox"] = "application/x-virtualbox-vbox"; + _map[".vbox-extpack"] = "application/x-virtualbox-vbox-extpack"; + _map[".vdi"] = "application/x-virtualbox-vdi"; + _map[".vhd"] = "application/x-virtualbox-vhd"; + _map[".vmdk"] = "application/x-virtualbox-vmdk"; + _map[".src"] = "application/x-wais-source"; + _map[".webapp"] = "application/x-web-app-manifest+json"; + _map[".der"] = "application/x-x509-ca-cert"; + _map[".crt"] = "application/x-x509-ca-cert"; + _map[".pem"] = "application/x-x509-ca-cert"; + _map[".fig"] = "application/x-xfig"; + _map[".xlf"] = "application/xliff+xml"; + _map[".xpi"] = "application/x-xpinstall"; + _map[".xz"] = "application/x-xz"; + _map[".zip"] = "application/zip"; + _map[".z1"] = "application/x-zmachine"; + _map[".z2"] = "application/x-zmachine"; + _map[".z3"] = "application/x-zmachine"; + _map[".z4"] = "application/x-zmachine"; + _map[".z5"] = "application/x-zmachine"; + _map[".z6"] = "application/x-zmachine"; + _map[".z7"] = "application/x-zmachine"; + _map[".z8"] = "application/x-zmachine"; + _map[".xaml"] = "application/xaml+xml"; + _map[".xav"] = "application/xcap-att+xml"; + _map[".xca"] = "application/xcap-caps+xml"; + _map[".xdf"] = "application/xcap-diff+xml"; + _map[".xel"] = "application/xcap-el+xml"; + _map[".xns"] = "application/xcap-ns+xml"; + _map[".xenc"] = "application/xenc+xml"; + _map[".xhtml"] = "application/xhtml+xml"; + _map[".xht"] = "application/xhtml+xml"; + _map[".xml"] = "application/xml"; + _map[".xsl"] = "application/xslt+xml"; + _map[".xsd"] = "application/xml"; + _map[".rng"] = "application/xml"; + _map[".dtd"] = "application/xml-dtd"; + _map[".xop"] = "application/xop+xml"; + _map[".xpl"] = "application/xproc+xml"; + _map[".xslt"] = "application/xslt+xml"; + _map[".xspf"] = "application/xspf+xml"; + _map[".mxml"] = "application/xv+xml"; + _map[".xhvml"] = "application/xv+xml"; + _map[".xvml"] = "application/xv+xml"; + _map[".xvm"] = "application/xv+xml"; + _map[".yang"] = "application/yang"; + _map[".yin"] = "application/yin+xml"; + _map[".lottie"] = "application/zip+dotlottie"; + _map[".3gpp"] = "video/3gpp"; + _map[".adts"] = "audio/aac"; + _map[".aac"] = "audio/aac"; + _map[".adp"] = "audio/adpcm"; + _map[".amr"] = "audio/amr"; + _map[".au"] = "audio/basic"; + _map[".snd"] = "audio/basic"; + _map[".mid"] = "audio/midi"; + _map[".midi"] = "audio/midi"; + _map[".kar"] = "audio/midi"; + _map[".rmi"] = "audio/midi"; + _map[".mxmf"] = "audio/mobile-xmf"; + _map[".mp3"] = "audio/mpeg"; + _map[".m4a"] = "audio/mp4"; + _map[".mp4a"] = "audio/mp4"; + _map[".m4b"] = "audio/mp4"; + _map[".mpga"] = "audio/mpeg"; + _map[".mp2"] = "audio/mpeg"; + _map[".mp2a"] = "audio/mpeg"; + _map[".m2a"] = "audio/mpeg"; + _map[".m3a"] = "audio/mpeg"; + _map[".oga"] = "audio/ogg"; + _map[".ogg"] = "audio/ogg"; + _map[".spx"] = "audio/ogg"; + _map[".opus"] = "audio/ogg"; + _map[".s3m"] = "audio/s3m"; + _map[".sil"] = "audio/silk"; + _map[".uva"] = "audio/vnd.dece.audio"; + _map[".uvva"] = "audio/vnd.dece.audio"; + _map[".eol"] = "audio/vnd.digital-winds"; + _map[".dra"] = "audio/vnd.dra"; + _map[".dts"] = "audio/vnd.dts"; + _map[".dtshd"] = "audio/vnd.dts.hd"; + _map[".lvp"] = "audio/vnd.lucent.voice"; + _map[".pya"] = "audio/vnd.ms-playready.media.pya"; + _map[".ecelp4800"] = "audio/vnd.nuera.ecelp4800"; + _map[".ecelp7470"] = "audio/vnd.nuera.ecelp7470"; + _map[".ecelp9600"] = "audio/vnd.nuera.ecelp9600"; + _map[".rip"] = "audio/vnd.rip"; + _map[".wav"] = "audio/wav"; + _map[".weba"] = "audio/webm"; + _map[".aif"] = "audio/x-aiff"; + _map[".aiff"] = "audio/x-aiff"; + _map[".aifc"] = "audio/x-aiff"; + _map[".caf"] = "audio/x-caf"; + _map[".flac"] = "audio/x-flac"; + _map[".mka"] = "audio/x-matroska"; + _map[".m3u"] = "audio/x-mpegurl"; + _map[".wax"] = "audio/x-ms-wax"; + _map[".wma"] = "audio/x-ms-wma"; + _map[".ram"] = "audio/x-pn-realaudio"; + _map[".ra"] = "audio/x-pn-realaudio"; + _map[".rmp"] = "audio/x-pn-realaudio-plugin"; + _map[".xm"] = "audio/xm"; + _map[".cdx"] = "chemical/x-cdx"; + _map[".cif"] = "chemical/x-cif"; + _map[".cmdf"] = "chemical/x-cmdf"; + _map[".cml"] = "chemical/x-cml"; + _map[".csml"] = "chemical/x-csml"; + _map[".xyz"] = "chemical/x-xyz"; + _map[".ttc"] = "font/collection"; + _map[".otf"] = "font/otf"; + _map[".ttf"] = "font/ttf"; + _map[".woff"] = "font/woff"; + _map[".woff2"] = "font/woff2"; + _map[".exr"] = "image/aces"; + _map[".apng"] = "image/apng"; + _map[".avci"] = "image/avci"; + _map[".avcs"] = "image/avcs"; + _map[".avif"] = "image/avif"; + _map[".bmp"] = "image/bmp"; + _map[".dib"] = "image/bmp"; + _map[".cgm"] = "image/cgm"; + _map[".drle"] = "image/dicom-rle"; + _map[".dpx"] = "image/dpx"; + _map[".fits"] = "image/fits"; + _map[".g3"] = "image/g3fax"; + _map[".gif"] = "image/gif"; + _map[".heic"] = "image/heic"; + _map[".heics"] = "image/heic-sequence"; + _map[".heif"] = "image/heif"; + _map[".heifs"] = "image/heif-sequence"; + _map[".hej2"] = "image/hej2k"; + _map[".ief"] = "image/ief"; + _map[".jaii"] = "image/jaii"; + _map[".jais"] = "image/jais"; + _map[".jls"] = "image/jls"; + _map[".jp2"] = "image/jp2"; + _map[".jpg2"] = "image/jp2"; + _map[".jpg"] = "image/jpeg"; + _map[".jpeg"] = "image/jpeg"; + _map[".jpe"] = "image/jpeg"; + _map[".jph"] = "image/jph"; + _map[".jhc"] = "image/jphc"; + _map[".jpm"] = "image/jpm"; + _map[".jpgm"] = "image/jpm"; + _map[".jpx"] = "image/jpx"; + _map[".jpf"] = "image/jpx"; + _map[".jxl"] = "image/jxl"; + _map[".jxr"] = "image/jxr"; + _map[".jxra"] = "image/jxra"; + _map[".jxrs"] = "image/jxrs"; + _map[".jxs"] = "image/jxs"; + _map[".jxsc"] = "image/jxsc"; + _map[".jxsi"] = "image/jxsi"; + _map[".jxss"] = "image/jxss"; + _map[".ktx"] = "image/ktx"; + _map[".ktx2"] = "image/ktx2"; + _map[".jfif"] = "image/pjpeg"; + _map[".png"] = "image/png"; + _map[".btif"] = "image/prs.btif"; + _map[".btf"] = "image/prs.btif"; + _map[".pti"] = "image/prs.pti"; + _map[".sgi"] = "image/sgi"; + _map[".svg"] = "image/svg+xml"; + _map[".svgz"] = "image/svg+xml"; + _map[".t38"] = "image/t38"; + _map[".tif"] = "image/tiff"; + _map[".tiff"] = "image/tiff"; + _map[".tfx"] = "image/tiff-fx"; + _map[".psd"] = "image/vnd.adobe.photoshop"; + _map[".azv"] = "image/vnd.airzip.accelerator.azv"; + _map[".uvi"] = "image/vnd.dece.graphic"; + _map[".uvvi"] = "image/vnd.dece.graphic"; + _map[".uvg"] = "image/vnd.dece.graphic"; + _map[".uvvg"] = "image/vnd.dece.graphic"; + _map[".djvu"] = "image/vnd.djvu"; + _map[".djv"] = "image/vnd.djvu"; + _map[".sub"] = "image/vnd.dvb.subtitle"; + _map[".dwg"] = "image/vnd.dwg"; + _map[".dxf"] = "image/vnd.dxf"; + _map[".fbs"] = "image/vnd.fastbidsheet"; + _map[".fpx"] = "image/vnd.fpx"; + _map[".fst"] = "image/vnd.fst"; + _map[".mmr"] = "image/vnd.fujixerox.edmics-mmr"; + _map[".rlc"] = "image/vnd.fujixerox.edmics-rlc"; + _map[".ico"] = "image/vnd.microsoft.icon"; + _map[".dds"] = "image/vnd.ms-dds"; + _map[".mdi"] = "image/vnd.ms-modi"; + _map[".wdp"] = "image/vnd.ms-photo"; + _map[".npx"] = "image/vnd.net-fpx"; + _map[".b16"] = "image/vnd.pco.b16"; + _map[".tap"] = "image/vnd.tencent.tap"; + _map[".vtf"] = "image/vnd.valve.source.texture"; + _map[".wbmp"] = "image/vnd.wap.wbmp"; + _map[".xif"] = "image/vnd.xiff"; + _map[".pcx"] = "image/x-pcx"; + _map[".webp"] = "image/webp"; + _map[".3ds"] = "image/x-3ds"; + _map[".dng"] = "image/x-adobe-dng"; + _map[".ras"] = "image/x-cmu-raster"; + _map[".cmx"] = "image/x-cmx"; + _map[".fh"] = "image/x-freehand"; + _map[".fhc"] = "image/x-freehand"; + _map[".fh4"] = "image/x-freehand"; + _map[".fh5"] = "image/x-freehand"; + _map[".fh7"] = "image/x-freehand"; + _map[".jng"] = "image/x-jng"; + _map[".sid"] = "image/x-mrsid-image"; + _map[".pic"] = "image/x-pict"; + _map[".pct"] = "image/x-pict"; + _map[".pnm"] = "image/x-portable-anymap"; + _map[".pbm"] = "image/x-portable-bitmap"; + _map[".pgm"] = "image/x-portable-graymap"; + _map[".ppm"] = "image/x-portable-pixmap"; + _map[".rgb"] = "image/x-rgb"; + _map[".tga"] = "image/x-tga"; + _map[".xbm"] = "image/x-xbitmap"; + _map[".xpm"] = "image/x-xpixmap"; + _map[".xwd"] = "image/x-xwindowdump"; + _map[".disposition-notification"] = "message/disposition-notification"; + _map[".u8msg"] = "message/global"; + _map[".u8dsn"] = "message/global-delivery-status"; + _map[".u8mdn"] = "message/global-disposition-notification"; + _map[".u8hdr"] = "message/global-headers"; + _map[".eml"] = "message/rfc822"; + _map[".mime"] = "message/rfc822"; + _map[".mht"] = "message/rfc822"; + _map[".mhtml"] = "message/rfc822"; + _map[".wsc"] = "message/vnd.wfa.wsc"; + _map[".3mf"] = "model/3mf"; + _map[".gltf"] = "model/gltf+json"; + _map[".glb"] = "model/gltf-binary"; + _map[".igs"] = "model/iges"; + _map[".iges"] = "model/iges"; + _map[".jt"] = "model/jt"; + _map[".msh"] = "model/mesh"; + _map[".mesh"] = "model/mesh"; + _map[".silo"] = "model/mesh"; + _map[".mtl"] = "model/mtl"; + _map[".step"] = "model/step"; + _map[".stp"] = "model/step"; + _map[".stpnc"] = "model/step"; + _map[".p21"] = "model/step"; + _map[".210"] = "model/step"; + _map[".stpx"] = "model/step+xml"; + _map[".stpz"] = "model/step+zip"; + _map[".stpxz"] = "model/step-xml+zip"; + _map[".u3d"] = "model/u3d"; + _map[".bary"] = "model/vnd.bary"; + _map[".cld"] = "model/vnd.cld"; + _map[".dae"] = "model/vnd.collada+xml"; + _map[".dwf"] = "model/vnd.dwf"; + _map[".gdl"] = "model/vnd.gdl"; + _map[".gtw"] = "model/vnd.gtw"; + _map[".mts"] = "video/mp2t"; + _map[".ogex"] = "model/vnd.opengex"; + _map[".x_b"] = "model/vnd.parasolid.transmit.binary"; + _map[".x_t"] = "model/vnd.parasolid.transmit.text"; + _map[".pyo"] = "model/vnd.pytha.pyox"; + _map[".pyox"] = "model/vnd.pytha.pyox"; + _map[".vds"] = "model/vnd.sap.vds"; + _map[".usda"] = "model/vnd.usda"; + _map[".usdz"] = "model/vnd.usdz+zip"; + _map[".bsp"] = "model/vnd.valve.source.compiled-map"; + _map[".vtu"] = "model/vnd.vtu"; + _map[".wrl"] = "model/vrml"; + _map[".vrml"] = "model/vrml"; + _map[".x3db"] = "model/x3d+binary"; + _map[".x3dbz"] = "model/x3d+binary"; + _map[".x3dv"] = "model/x3d+vrml"; + _map[".x3dvz"] = "model/x3d+vrml"; + _map[".x3d"] = "model/x3d+xml"; + _map[".x3dz"] = "model/x3d+xml"; + _map[".appcache"] = "text/cache-manifest"; + _map[".manifest"] = "text/cache-manifest"; + _map[".ics"] = "text/calendar"; + _map[".ifb"] = "text/calendar"; + _map[".coffee"] = "text/coffeescript"; + _map[".litcoffee"] = "text/coffeescript"; + _map[".css"] = "text/css"; + _map[".csv"] = "text/csv"; + _map[".html"] = "text/html"; + _map[".htm"] = "text/html"; + _map[".shtml"] = "text/html"; + _map[".jade"] = "text/jade"; + _map[".mjs"] = "text/javascript"; + _map[".jsx"] = "text/jsx"; + _map[".less"] = "text/less"; + _map[".md"] = "text/markdown"; + _map[".markdown"] = "text/markdown"; + _map[".mml"] = "text/mathml"; + _map[".mdx"] = "text/mdx"; + _map[".n3"] = "text/n3"; + _map[".txt"] = "text/plain"; + _map[".text"] = "text/plain"; + _map[".conf"] = "text/plain"; + _map[".def"] = "text/plain"; + _map[".list"] = "text/plain"; + _map[".log"] = "text/plain"; + _map[".in"] = "text/plain"; + _map[".ini"] = "text/plain"; + _map[".dsc"] = "text/prs.lines.tag"; + _map[".rtx"] = "text/richtext"; + _map[".sgml"] = "text/sgml"; + _map[".sgm"] = "text/sgml"; + _map[".shex"] = "text/shex"; + _map[".slim"] = "text/slim"; + _map[".slm"] = "text/slim"; + _map[".spdx"] = "text/spdx"; + _map[".stylus"] = "text/stylus"; + _map[".styl"] = "text/stylus"; + _map[".tsv"] = "text/tab-separated-values"; + _map[".t"] = "text/troff"; + _map[".tr"] = "text/troff"; + _map[".roff"] = "text/troff"; + _map[".man"] = "text/troff"; + _map[".me"] = "text/troff"; + _map[".ms"] = "text/troff"; + _map[".ttl"] = "text/turtle"; + _map[".uri"] = "text/uri-list"; + _map[".uris"] = "text/uri-list"; + _map[".urls"] = "text/uri-list"; + _map[".vcard"] = "text/vcard"; + _map[".curl"] = "text/vnd.curl"; + _map[".dcurl"] = "text/vnd.curl.dcurl"; + _map[".mcurl"] = "text/vnd.curl.mcurl"; + _map[".scurl"] = "text/vnd.curl.scurl"; + _map[".ged"] = "text/vnd.familysearch.gedcom"; + _map[".fly"] = "text/vnd.fly"; + _map[".flx"] = "text/vnd.fmi.flexstor"; + _map[".gv"] = "text/vnd.graphviz"; + _map[".3dml"] = "text/vnd.in3d.3dml"; + _map[".spot"] = "text/vnd.in3d.spot"; + _map[".jad"] = "text/vnd.sun.j2me.app-descriptor"; + _map[".wml"] = "text/vnd.wap.wml"; + _map[".wmls"] = "text/vnd.wap.wmlscript"; + _map[".vtt"] = "text/vtt"; + _map[".wgsl"] = "text/wgsl"; + _map[".s"] = "text/x-asm"; + _map[".asm"] = "text/x-asm"; + _map[".c"] = "text/x-c"; + _map[".cc"] = "text/x-c"; + _map[".cxx"] = "text/x-c"; + _map[".cpp"] = "text/x-c"; + _map[".h"] = "text/x-c"; + _map[".hh"] = "text/x-c"; + _map[".dic"] = "text/x-c"; + _map[".htc"] = "text/x-component"; + _map[".f"] = "text/x-fortran"; + _map[".for"] = "text/x-fortran"; + _map[".f77"] = "text/x-fortran"; + _map[".f90"] = "text/x-fortran"; + _map[".hbs"] = "text/x-handlebars-template"; + _map[".java"] = "text/x-java-source"; + _map[".lua"] = "text/x-lua"; + _map[".mkd"] = "text/x-markdown"; + _map[".nfo"] = "text/x-nfo"; + _map[".opml"] = "text/x-opml"; + _map[".p"] = "text/x-pascal"; + _map[".pas"] = "text/x-pascal"; + _map[".pde"] = "text/x-processing"; + _map[".sass"] = "text/x-sass"; + _map[".scss"] = "text/x-scss"; + _map[".etx"] = "text/x-setext"; + _map[".sfv"] = "text/x-sfv"; + _map[".ymp"] = "text/x-suse-ymp"; + _map[".uu"] = "text/x-uuencode"; + _map[".vcs"] = "text/x-vcalendar"; + _map[".vcf"] = "text/x-vcard"; + _map[".yaml"] = "application/yaml"; + _map[".yml"] = "application/yaml"; + _map[".3gp"] = "video/3gpp"; + _map[".3g2"] = "video/3gpp2"; + _map[".h261"] = "video/h261"; + _map[".h263"] = "video/h263"; + _map[".h264"] = "video/h264"; + _map[".m4s"] = "video/iso.segment"; + _map[".jpgv"] = "video/jpeg"; + _map[".mj2"] = "video/mj2"; + _map[".mjp2"] = "video/mj2"; + _map[".ts"] = "video/mp2t"; + _map[".m2t"] = "video/mp2t"; + _map[".m2ts"] = "video/mp2t"; + _map[".mp4v"] = "video/mp4"; + _map[".mpeg"] = "video/mpeg"; + _map[".mpg"] = "video/mpeg"; + _map[".mpe"] = "video/mpeg"; + _map[".m1v"] = "video/mpeg"; + _map[".m2v"] = "video/mpeg"; + _map[".ogv"] = "video/ogg"; + _map[".qt"] = "video/quicktime"; + _map[".mov"] = "video/quicktime"; + _map[".uvh"] = "video/vnd.dece.hd"; + _map[".uvvh"] = "video/vnd.dece.hd"; + _map[".uvm"] = "video/vnd.dece.mobile"; + _map[".uvvm"] = "video/vnd.dece.mobile"; + _map[".uvp"] = "video/vnd.dece.pd"; + _map[".uvvp"] = "video/vnd.dece.pd"; + _map[".uvs"] = "video/vnd.dece.sd"; + _map[".uvvs"] = "video/vnd.dece.sd"; + _map[".uvv"] = "video/vnd.dece.video"; + _map[".uvvv"] = "video/vnd.dece.video"; + _map[".dvb"] = "video/vnd.dvb.file"; + _map[".fvt"] = "video/vnd.fvt"; + _map[".mxu"] = "video/vnd.mpegurl"; + _map[".m4u"] = "video/vnd.mpegurl"; + _map[".pyv"] = "video/vnd.ms-playready.media.pyv"; + _map[".uvu"] = "video/vnd.uvvu.mp4"; + _map[".uvvu"] = "video/vnd.uvvu.mp4"; + _map[".viv"] = "video/vnd.vivo"; + _map[".webm"] = "video/webm"; + _map[".f4v"] = "video/x-f4v"; + _map[".fli"] = "video/x-fli"; + _map[".flv"] = "video/x-flv"; + _map[".m4v"] = "video/x-m4v"; + _map[".mkv"] = "video/x-matroska"; + _map[".mk3d"] = "video/x-matroska"; + _map[".mks"] = "video/x-matroska"; + _map[".mng"] = "video/x-mng"; + _map[".asf"] = "video/x-ms-asf"; + _map[".asx"] = "video/x-ms-asf"; + _map[".vob"] = "video/x-ms-vob"; + _map[".wm"] = "video/x-ms-wm"; + _map[".wmv"] = "video/x-ms-wmv"; + _map[".wmx"] = "video/x-ms-wmx"; + _map[".wvx"] = "video/x-ms-wvx"; + _map[".avi"] = "video/x-msvideo"; + _map[".movie"] = "video/x-sgi-movie"; + _map[".smv"] = "video/x-smv"; + _map[".ice"] = "x-conference/x-cooltalk"; + #endregion + + // ---------------------------------------------------------------------- + // Non-IANA but commonly used MIME types (manual additions) + // ---------------------------------------------------------------------- + + #region Source code & scripting + _map[".cs"] = "text/plain"; // C# + _map[".ps1"] = "text/x-powershell"; + _map[".psm1"] = "text/x-powershell"; + _map[".psd1"] = "text/x-powershell"; + _map[".sh"] = "text/x-shellscript"; + _map[".bash"] = "text/x-shellscript"; + _map[".zsh"] = "text/x-shellscript"; + _map[".bat"] = "text/plain"; + _map[".cmd"] = "text/plain"; + _map[".c"] = "text/plain"; + _map[".h"] = "text/plain"; + _map[".cpp"] = "text/plain"; + _map[".hpp"] = "text/plain"; + _map[".java"] = "text/x-java-source"; + _map[".kt"] = "text/plain"; // Kotlin + _map[".swift"] = "text/plain"; + _map[".go"] = "text/plain"; + // _map[".rs"] = "text/plain"; // Rust + _map[".py"] = "text/x-python"; + _map[".rb"] = "text/x-ruby"; + _map[".pl"] = "text/x-perl"; + _map[".proto"] = "text/plain"; + _map[".sql"] = "text/plain"; + _map[".gradle"] = "text/x-groovy"; + _map[".groovy"] = "text/x-groovy"; + _map[".gemspec"] = "text/x-ruby"; + + #endregion + + #region Config & meta + _map[".ini"] = "text/plain"; + _map[".conf"] = "text/plain"; + _map[".env"] = "text/plain"; + _map[".toml"] = "application/toml"; // not IANA yet + _map[".yaml"] = "application/x-yaml"; + _map[".yml"] = "application/x-yaml"; + _map[".md"] = "text/markdown"; // widely accepted but not official + _map[".rst"] = "text/x-rst"; + #endregion + + #region Webdev + _map[".ts"] = "application/x-typescript"; + _map[".tsx"] = "application/x-typescript"; + _map[".jsx"] = "text/jsx"; + _map[".vue"] = "text/plain"; + _map[".scss"] = "text/x-scss"; + _map[".sass"] = "text/x-sass"; + _map[".less"] = "text/x-less"; + _map[".styl"] = "text/x-stylus"; + _map[".coffee"] = "text/x-coffeescript"; + #endregion + + #region Data formats (not officially IANA-registered) + _map[".parquet"] = "application/x-parquet"; + _map[".avro"] = "application/avro"; + #endregion + + #region Build / dev + _map[".makefile"] = "text/x-makefile"; + _map[".Dockerfile"] = "text/plain"; + _map[".dockerignore"] = "text/plain"; // Docker ignore file + _map[".gitignore"] = "text/plain"; // Git ignore file + _map[".gitattributes"] = "text/plain"; // Git attributes file + _map[".npmignore"] = "text/plain"; // NPM ignore file + _map[".yarnignore"] = "text/plain"; // Yarn ignore file + _map[".sln"] = "text/plain"; // Solution file + _map[".csproj"] = "text/plain"; // C# project file + _map[".vbproj"] = "text/plain"; // VB.NET project file + _map[".gradle"] = "text/plain"; // Gradle build file + _map[".pom"] = "text/plain"; // Maven POM file + _map[".build"] = "text/plain"; // Generic build file + _map[".bazel"] = "text/plain"; // Bazel build file + _map[".buck"] = "text/plain"; // Buck build file + _map[".make"] = "text/x-makefile"; // Makefile + _map[".cmake"] = "text/x-cmake"; // CMake file + _map[".cmake.in"] = "text/x-cmake"; // CMake input file + _map["nuget.config"] = "text/xml"; // NuGet configuration file + _map[".npmrc"] = "text/plain"; // NPM configuration file + _map[".yarnrc"] = "text/plain"; // Yarn configuration file + _map[".vscode"] = "text/plain"; // VS Code configuration directory + _map[".vscodeignore"] = "text/plain"; // VS Code ignore file + _map[".eslintrc"] = "text/plain"; // ESLint configuration + _map[".prettierrc"] = "text/plain"; // Prettier configuration + _map[".babelrc"] = "text/plain"; // Babel configuration + _map[".webpack.config.js"] = "text/plain"; // Webpack configuration + _map[".rollup.config.js"] = "text/plain"; // Rollup configuration + _map[".gulpfile.js"] = "text/plain"; // Gulp configuration + _map[".gruntfile.js"] = "text/plain"; // Grunt configuration + _map[".docker-compose.yml"] = "text/x-yaml"; // Docker Compose YAML file + _map[".docker-compose.yaml"] = "text/x-yaml"; // Docker Compose YAML file + _map[".lock"] = "text/plain"; // Generic lock file + _map[".lockfile"] = "text/plain"; // Generic lock file + #endregion + } + + /* ────────────────────────────── + * Public API + * ────────────────────────────── */ + + /// True if a mapping exists for . + public static bool Contains(string extension) => + _map.ContainsKey(NormalizeExt(extension)); + + /// Try to get the type; returns false if unknown. + public static bool TryGet(string extension, out string contentType) => + _map.TryGetValue(NormalizeExt(extension), out contentType); + + /// + /// Get the content type for the given extension. + /// If the extension is not known, returns "application/octet-stream". + /// + /// The file extension to look up. + /// The content type for the given extension, or "application/octet-stream" if unknown. + public static string Get(string extension) => + _map.TryGetValue(NormalizeExt(extension), out var type) + ? type + : "application/octet-stream"; + + /// + /// Add or update a mapping for the given extension. + /// If the extension already exists, it will be updated with the new content type. + /// + /// The file extension to add or update. + /// The MIME type to associate with the extension. + public static void AddOrUpdate(string extension, string contentType) => + _map[NormalizeExt(extension)] = contentType; + + /// + /// Add a new mapping. + /// Throws if the extension already exists. + /// + /// The file extension to add. + /// The MIME type to associate with the extension. + /// + public static void Add(string extension, string contentType) + { + var ext = NormalizeExt(extension); + if (_map.ContainsKey(ext)) + throw new ArgumentException($"Extension '{ext}' already exists in the mapping."); + _map[ext] = contentType; + } + + /// Remove a mapping. Returns false if it wasn’t present. + public static bool Remove(string extension) => + _map.Remove(NormalizeExt(extension)); + + /// + /// Bulk-load mappings from a file in + /// “type ext1 ext2 …” format (e.g., Apache list). + /// + public static void LoadFromFile(string path) + { + foreach (var line in File.ReadLines(path)) + { + if (string.IsNullOrWhiteSpace(line) || line[0] == '#') continue; + var parts = line.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) continue; + + var contentType = parts[0]; + for (int i = 1; i < parts.Length; i++) + _map[NormalizeExt(parts[i])] = contentType; + } + } + + /// Read-only snapshot of the current table. + public static IReadOnlyDictionary All => _map; + + /* ────────────────────────────── */ + private static string NormalizeExt(string ext) + { + if (string.IsNullOrEmpty(ext)) + return string.Empty; + + return ext[0] == '.' ? ext : "." + ext; + } + + + public static bool IsTextualMimeType(string type) + { + if (type.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) + return true; + + // Include structured types using XML or JSON suffixes + if (type.EndsWith("+xml", StringComparison.OrdinalIgnoreCase) || + type.EndsWith("+json", StringComparison.OrdinalIgnoreCase)) + return true; + + // Common application types where charset makes sense + switch (type.ToLowerInvariant()) + { + case "application/json": + case "application/xml": + case "application/javascript": + case "application/xhtml+xml": + case "application/x-www-form-urlencoded": + case "application/yaml": + case "application/graphql": + return true; + } + + return false; + } + + } +} \ No newline at end of file diff --git a/src/Listener/PodeResponse.cs b/src/Listener/PodeResponse.cs index bd4e54e13..c1a579e8f 100644 --- a/src/Listener/PodeResponse.cs +++ b/src/Listener/PodeResponse.cs @@ -1,8 +1,11 @@ using System; +using System.Buffers; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Net; using System.Text; using System.Threading.Tasks; @@ -13,13 +16,16 @@ public class PodeResponse : IDisposable { protected const int MAX_FRAME_SIZE = 8192; + // “Small” files up to 64 MiB get buffered in-memory; anything larger is streamed. + private const long MAX_IN_MEMORY_FILE_SIZE = 64L * 1024 * 1024; + public PodeResponseHeaders Headers { get; private set; } public int StatusCode = 200; public bool SendChunked = false; public MemoryStream OutputStream { get; private set; } public bool IsDisposed { get; private set; } - private PodeContext Context; + private readonly PodeContext Context; private PodeRequest Request { get => Context.Request; } public PodeSseScope SseScope { get; private set; } = PodeSseScope.None; @@ -63,7 +69,10 @@ public long ContentLength64 } set { - Headers.Set("Content-Length", value); + if (value > 0) + { + Headers.Set("Content-Length", value); + } } } @@ -80,6 +89,22 @@ public string HttpResponseLine private static readonly UTF8Encoding Encoding = new UTF8Encoding(); + /// + /// PodeResponse constructor for testing purposes. + /// This constructor initializes the response with default headers and an empty output stream. + /// + public PodeResponse() + { + Headers = new PodeResponseHeaders(); + OutputStream = new MemoryStream(); + Context = null; // This constructor is not meant to be used directly, but for testing purposes. + } + + /// + /// PodeResponse class represents an HTTP response in the Pode framework. + /// It encapsulates the response headers, status code, output stream, and methods to send the response. + /// + /// public PodeResponse(PodeContext context) { Headers = new PodeResponseHeaders(); @@ -87,6 +112,42 @@ public PodeResponse(PodeContext context) Context = context; } + /// + /// Creates a new PodeResponse instance by copying the properties from another PodeResponse instance. + /// This is useful for creating a response that is similar to an existing one, such as in a middleware scenario. + /// + /// + public PodeResponse(PodeResponse other) + { + // Copy the status code and other scalar values + StatusCode = other.StatusCode; + SendChunked = other.SendChunked; + IsDisposed = other.IsDisposed; + SseScope = other.SseScope; + SentHeaders = other.SentHeaders; + SentBody = other.SentBody; + _statusDesc = other._statusDesc; + + // Create a new memory stream and copy the content of the other stream + OutputStream = new MemoryStream(); + other.OutputStream.CopyTo(OutputStream); + + // Copy the headers (assuming PodeResponseHeaders supports cloning or deep copy) + Headers = new PodeResponseHeaders(); + foreach (var key in other.Headers.Keys) + { + Headers.Set(key, other.Headers[key]); + } + + // Copy the context and request, or create new instances if necessary (context should probably be reused) + Context = other.Context; + } + + + /// + /// Sends the complete HTTP response, including headers and body, to the client. + /// + /// A task representing the asynchronous operation. public async Task Send() { if (Sent || IsDisposed || (SentHeaders && SseEnabled)) @@ -119,6 +180,10 @@ public async Task Send() } } + /// + /// Sends a timeout (408) response when the client times out. + /// + /// A task representing the asynchronous operation. public async Task SendTimeout() { if (SentHeaders || IsDisposed) @@ -151,6 +216,12 @@ public async Task SendTimeout() } } + /// + /// Sends the HTTP headers to the client. + /// If `timeout` is true, it clears existing headers and sets default headers. + /// + /// + /// private async Task SendHeaders(bool timeout) { if (SentHeaders || !Request.InputStream.CanWrite) @@ -189,6 +260,13 @@ private async Task SendBody(bool timeout) SentBody = true; } + /// + /// Flushes the response stream to the client. + /// This method ensures that any buffered data is sent to the client immediately. + /// It checks if the input stream can be written to before attempting to flush. + /// If the input stream is not writable, it does nothing. + /// + /// A task representing the asynchronous operation. public async Task Flush() { if (Request.InputStream.CanWrite) @@ -197,7 +275,20 @@ public async Task Flush() } } - public async Task SetSseConnection(PodeSseScope scope, string clientId, string name, string group, int retry, bool allowAllOrigins) + /// + /// Establishes an SSE (Server-Sent Events) connection with appropriate headers. + /// This method sets the SSE scope, client ID, name, group, retry interval, and allows cross-origin requests if specified. + /// It sends the initial headers and an open event to the client, and caches the connection if the scope is global. + /// + /// Scope of the SSE (Local/Global). + /// Optional SSE client ID. + /// Name of the connection. + /// Group the SSE connection belongs to. + /// Reconnect retry interval (ms). + /// Allow cross-origin requests. + /// Async route task ID (optional). + /// The client ID used for the SSE connection. + public async Task SetSseConnection(PodeSseScope scope, string clientId, string name, string group, int retry, bool allowAllOrigins, string asyncRouteTaskId = null) { // do nothing for no scope if (scope == PodeSseScope.None) @@ -237,7 +328,11 @@ public async Task SetSseConnection(PodeSseScope scope, string clientId, // send headers, and open event await Send().ConfigureAwait(false); await SendSseRetry(retry).ConfigureAwait(false); - await SendSseEvent("pode.open", $"{{\"clientId\":\"{clientId}\",\"group\":\"{group}\",\"name\":\"{name}\"}}").ConfigureAwait(false); + string sseEvent = (string.IsNullOrEmpty(asyncRouteTaskId)) ? + $"{{\"clientId\":\"{clientId}\",\"group\":\"{group}\",\"name\":\"{name}\"}}" : + $"{{\"clientId\":\"{clientId}\",\"group\":\"{group}\",\"name\":\"{name}\",\"asyncRouteTaskId\":\"{asyncRouteTaskId}\"}}"; + + await SendSseEvent("pode.open", sseEvent).ConfigureAwait(false); // if global, cache connection in listener if (scope == PodeSseScope.Global) @@ -249,11 +344,27 @@ public async Task SetSseConnection(PodeSseScope scope, string clientId, return clientId; } + /// + /// Sends a "close" SSE event to the client to terminate the connection. + /// This method is typically used to gracefully close an SSE connection. + /// It sends an event with the type "pode.close" and no data, indicating that the server is closing the connection. + /// + /// A task representing the asynchronous operation. public async Task CloseSseConnection() { await SendSseEvent("pode.close", string.Empty).ConfigureAwait(false); } + /// + /// Sends a named SSE event with optional ID. + /// This method allows sending custom events to the SSE client, which can be used for real-time updates or notifications. + /// It constructs the event with a type, data, and an optional ID, and writes it to the response stream. + /// The event type can be used to differentiate between different kinds of events on the client side. + /// + /// Event type name. + /// Event data string. + /// Optional event ID. + /// A task representing the asynchronous operation. public async Task SendSseEvent(string eventType, string data, string id = null) { if (!string.IsNullOrEmpty(id)) @@ -269,6 +380,13 @@ public async Task SendSseEvent(string eventType, string data, string id = null) await WriteLine($"data: {data}{PodeHelpers.NEW_LINE}", true).ConfigureAwait(false); } + /// + /// Sends a retry interval directive to the SSE client. + /// This method is used to inform the client how long it should wait before attempting to reconnect after a disconnection. + /// The retry interval is specified in milliseconds, and this directive helps manage the reconnection attempts in a controlled manner. + /// + /// Retry interval in milliseconds. + /// A task representing the asynchronous operation. public async Task SendSseRetry(int retry) { if (retry <= 0) @@ -279,6 +397,13 @@ public async Task SendSseRetry(int retry) await WriteLine($"retry: {retry}", true).ConfigureAwait(false); } + /// + /// Sends a raw signal string through the response stream. + /// This method is typically used to send server signals or messages that do not require any specific formatting. + /// It writes the signal value directly to the response stream, allowing for immediate communication with the client. + /// + /// The signal object to send. + /// A task representing the asynchronous operation. public async Task SendSignal(PodeServerSignal signal) { if (!string.IsNullOrEmpty(signal.Value)) @@ -287,6 +412,14 @@ public async Task SendSignal(PodeServerSignal signal) } } + /// + /// Writes a string message to the response, either directly or over a WebSocket. + /// This method checks if the context is a WebSocket connection and writes the message accordingly. + /// If the context is not a WebSocket, it encodes the message to bytes and writes it directly to the response stream. + /// + /// Message to send. + /// Whether to flush immediately after writing. + /// A task representing the asynchronous operation. public async Task Write(string message, bool flush = false) { // simple messages @@ -302,6 +435,15 @@ public async Task Write(string message, bool flush = false) } } + /// + /// Writes a WebSocket frame message to the response stream. + /// This method handles the framing of the message according to the WebSocket protocol, including setting the FIN bit, operation code, and payload length. + /// It supports both text and binary messages, and can handle large messages by splitting them into smaller frames. + /// + /// Message to send. + /// WebSocket operation code. + /// Whether to flush immediately after writing. + /// A task representing the asynchronous operation. public async Task WriteFrame(string message, PodeWsOpCode opCode = PodeWsOpCode.Text, bool flush = false) { if (IsDisposed) @@ -361,12 +503,28 @@ public async Task WriteFrame(string message, PodeWsOpCode opCode = PodeWsOpCode. } } + /// + /// Writes a line of text to the response, followed by a newline. + /// This method encodes the message to bytes and writes it to the response stream. + /// It is typically used for sending log messages or simple text responses. + /// + /// Message to send. + /// Whether to flush immediately after writing. + /// A task representing the asynchronous operation. public async Task WriteLine(string message, bool flush = false) { await Write(Encoding.GetBytes($"{message}{PodeHelpers.NEW_LINE}"), flush).ConfigureAwait(false); } // write a byte array to the actual client stream + /// + /// Writes a byte array to the response stream. + /// This method checks if the request input stream is writable before attempting to write. + /// If the request is disposed or the input stream cannot be written to, it does nothing. + /// + /// Buffer of bytes to send. + /// Whether to flush immediately after writing. + /// A task representing the asynchronous operation. public async Task Write(byte[] buffer, bool flush = false) { if (Request.IsDisposed || !Request.InputStream.CanWrite) @@ -399,35 +557,510 @@ public async Task Write(byte[] buffer, bool flush = false) throw; } } + private static Stream WrapCompression(Stream destination, + PodeCompressionType compression, + out string contentEncoding) + { + contentEncoding = compression.ToString(); + + switch (compression) + { + case PodeCompressionType.gzip: + return new GZipStream(destination, CompressionLevel.Fastest, leaveOpen: true); + +#if NETCOREAPP2_1_OR_GREATER // <-- define this for TFMs that have BrotliStream + case PodeCompressionType.br: + return new BrotliStream(destination, CompressionLevel.Fastest, true); +#else + case PodeCompressionType.br: + throw new NotSupportedException( + "Brotli compression requires System.IO.Compression.Brotli (netcore2.1+/net5.0+)."); +#endif + case PodeCompressionType.deflate: + return new DeflateStream(destination, CompressionLevel.Fastest, leaveOpen: true); + + default: + return destination; // no compression + } + } + - public void WriteFile(string path) + + #region Stream Writing + + /// + /// Writes a file from a given path to the response. + /// This method checks if the file exists and if it is small enough to be buffered in memory. + /// If the file is larger than the defined maximum size, it streams the file directly to the response. + /// + /// The file path. + /// Optional PowerShell array of @{ Start; End } hashtables. + /// Optional compression type for the file. + /// A task representing the asynchronous operation. + public async Task WriteFileAsync(string path, long[] ranges = null, PodeCompressionType compression = PodeCompressionType.none) { - WriteFile(new FileInfo(path)); + await WriteFileAsync(new FileInfo(path), ranges, compression).ConfigureAwait(false); } - public void WriteFile(FileSystemInfo file) + /// + /// Writes a stream to the response, using buffering for small payloads and streaming for large ones. + /// + /// - If the stream is ≤ 64 MiB (or length is known and small), it is fully buffered into memory and a Content-Length header is set. + /// - If the stream is large, it is streamed in chunks using WriteLargeStream. + /// - If compression is enabled, the stream is compressed and chunked regardless of size. + /// + /// + /// The input to write. Must be readable. If compression is not used, should be seekable when possible. + /// + /// + /// Optional total length of the stream. If not provided, src.Length is used if the stream supports seeking. + /// + /// + /// Optional list of byte ranges to write, represented as hashtables with Start and End keys. + /// Mutually exclusive with compression. + /// + /// + /// Optional compression type to apply (Gzip, Deflate, Brotli). + /// Compression and range-based streaming cannot be used together. + /// + /// + /// A representing the asynchronous write operation. + /// + /// + /// Thrown if both compression and ranges are supplied. + /// + /// + /// When compression is used, the response is sent with chunked encoding and no Content-Length. + /// If neither ranges nor compression are used, the method buffers small files and streams large ones using WriteLargeStream. + /// + public async Task WriteStreamAsync(Stream src, long? length = null, long[] ranges = null, PodeCompressionType compression = PodeCompressionType.none) { - if (IsDisposed) +#if DEBUG + // Caller must not mix ranges + compression (handled upstream) + if (compression != PodeCompressionType.none && ranges?.Length > 0) + throw new InvalidOperationException("Compression with Range is not supported."); +#endif + if (compression != PodeCompressionType.none) + { + // Disable Pode request timeout for long transfers + Context.CancelTimeout(); + SendChunked = true; // tells Pode not to emit Content-Length + ContentLength64 = 0; // defensive; will be ignored when chunked + try + { + string _; // headers set by caller + using (var cmp = WrapCompression(OutputStream, compression, out _)) + { + await src.CopyToAsync( + cmp, + MAX_FRAME_SIZE, + Context.Listener.CancellationToken + ).ConfigureAwait(false); + } // disposing ‘cmp’ flushes the final gzip/deflate frame + + } + catch (Exception ex) + { + PodeHelpers.WriteException(ex, Context.Listener); + throw; + } + finally + { + OutputStream.Position = 0; + SendChunked = false; // we now know the size + ContentLength64 = OutputStream.Length; + } + return; // done – no size guessing, no buffering + } + + // small (≤ 64 MiB) → buffer + Content-Length + long size = length ?? (src.CanSeek ? src.Length : -1); + + if (size >= 0 && size <= MAX_IN_MEMORY_FILE_SIZE && ranges == null) + { + ContentLength64 = size; + await src.CopyToAsync(OutputStream).ConfigureAwait(false); + return; + } + // large (> 64 MiB) → existing streaming helper + await WriteLargeStream(src, size, ranges).ConfigureAwait(false); + } + + /// + /// Asynchronously streams the contents of a file to the client, with optional byte ranges and compression. + /// + /// + /// The representing the file to stream. Must be a valid and exist. + /// + /// + /// Optional list of byte ranges to stream, each represented as a hashtable with Start and End keys. + /// If null or empty, the entire file is streamed. + /// + /// + /// Optional compression type to apply to the output (e.g., Gzip, Brotli, Deflate). + /// Defaults to None. + /// + /// + /// A representing the asynchronous file streaming operation. + /// + /// + /// Thrown if is not a or the file does not exist. + /// + /// + /// Internally opens the file as a read-only stream and passes it to WriteStreamAsync. + /// Uses a classic using block for stream disposal for compatibility with older C# versions. + /// + public async Task WriteFileAsync(FileSystemInfo file, long[] ranges, PodeCompressionType compression = PodeCompressionType.none) + { + // C#≤8 compatible type check + var fi = file as FileInfo; + if (fi == null || !fi.Exists) + throw new FileNotFoundException( + string.Format("File not found: {0}", file.FullName)); + + // classic using-statement (await-using/IAsyncDisposable requires C# 8+) + using (FileStream src = fi.OpenRead()) + { + await WriteStreamAsync(src, fi.Length, ranges, compression) + .ConfigureAwait(false); + } + } + + public async Task WriteByteAsync(byte[] bytes, long[] ranges = null, PodeCompressionType compression = PodeCompressionType.none) + { + if (bytes == null) + throw new ArgumentNullException(nameof(bytes)); + using (MemoryStream ms = new MemoryStream(bytes, writable: false)) + { + await WriteStreamAsync(ms, bytes.Length, ranges, compression) + .ConfigureAwait(false); + } + } + + public void WriteBody(byte[] bytes, long[] ranges = null, PodeCompressionType compression = PodeCompressionType.none) + { + WriteByteAsync(bytes, ranges, compression).GetAwaiter().GetResult(); + } + + + + public void WriteBody(byte[] bytes, PodeCompressionType compression = PodeCompressionType.none) + { + WriteByteAsync(bytes, null, compression).GetAwaiter().GetResult(); + } + + /// + /// Asynchronously writes a string response to the client, with optional encoding and compression. + /// + /// + /// The string content to write. Must not be null. + /// + /// + /// Optional to use when converting the string to bytes. + /// If not specified, the current Encoding property is used. + /// + /// + /// Optional compression type to apply to the output (e.g., Gzip, Brotli, Deflate). + /// Defaults to None. + /// + /// + /// A representing the asynchronous write operation. + /// + /// + /// Thrown if is null. + /// + /// + /// The string is converted to a byte array and written to the client using WriteStreamAsync. + /// The underlying stream is disposed after writing. + /// + public async Task WriteStringAsync(string content, Encoding encoding = null, long[] ranges = null, PodeCompressionType compression = PodeCompressionType.none) + { + if (content == null) + throw new ArgumentNullException("content"); + + if (encoding == null) + encoding = Encoding; + + byte[] bytes = encoding.GetBytes(content); + + using (MemoryStream ms = new MemoryStream(bytes, writable: false)) + { + await WriteStreamAsync(ms, bytes.Length, ranges, compression) + .ConfigureAwait(false); + } + } + + /// + /// Synchronous façade for PowerShell callers that don’t use 'await'. + /// Just forwards to and blocks. + /// This method writes a string response to the client, with optional encoding and compression. + /// It is designed to be used in scenarios where synchronous execution is required, such as in Power + /// + /// The string content to write. + /// Optional encoding to use when converting the string to bytes. + /// Optional array of hashtables representing byte ranges to write. + /// Optional compression type to apply to the output. + public void WriteBody(string content, Encoding encoding = null, long[] ranges = null, PodeCompressionType compression = PodeCompressionType.none) + { + WriteStringAsync(content, encoding, ranges, compression).GetAwaiter().GetResult(); + } + + + + /// + /// Synchronous façade for PowerShell callers that don’t use 'await'. + /// Just forwards to and blocks. + /// Writes a string response to the client, with optional encoding and compression. + /// This method is designed to be used in scenarios where synchronous execution is required, such as in PowerShell scripts. + /// It allows for writing string content directly to the response stream, optionally applying encoding and compression. + /// + /// The string content to write. + /// Optional encoding to use when converting the string to bytes. + /// Optional compression type to apply to the output. + public void WriteBody(string content, Encoding encoding = null, PodeCompressionType compression = PodeCompressionType.none) + { + WriteStringAsync(content, encoding, null, compression).GetAwaiter().GetResult(); + } + + /// + /// Synchronous façade for PowerShell callers that don’t use 'await'. + /// Just forwards to and blocks. + /// Writes a string response to the client, with optional compression. + /// + /// The string content to write. + /// Optional compression type to apply to the output. + public void WriteBody(string content, PodeCompressionType compression = PodeCompressionType.none) + { + WriteStringAsync(content, null, null, compression).GetAwaiter().GetResult(); + } + + /// + /// Synchronous façade for PowerShell callers that don’t use 'await'. + /// Just forwards to and blocks. + /// + /// File system object representing the file. + /// Optional PowerShell array of @{ Start; End } hashtables. + /// Optional compression type for the file. + public void WriteFile(FileSystemInfo file, long[] ranges, PodeCompressionType compression = PodeCompressionType.none) + { + WriteFileAsync(file, ranges, compression).GetAwaiter().GetResult(); + } + + /// + /// Synchronous façade for PowerShell callers that don’t use 'await'. + /// Just forwards to and blocks. + /// + /// File system object representing the file. + /// Optional compression type for the file. + public void WriteFile(FileSystemInfo file, PodeCompressionType compression = PodeCompressionType.none) + { + WriteFileAsync(file, null, compression).GetAwaiter().GetResult(); + } + + + + /// + /// Synchronous façade for PowerShell callers that don’t use 'await'. + /// Just forwards to and blocks. + /// + /// The file path. + /// PowerShell array of @{ Start; End } hashtables. + /// Optional compression type for the file. + public void WriteFile(string path, long[] ranges, PodeCompressionType compression = PodeCompressionType.none) + { + WriteFileAsync(new FileInfo(path), ranges, compression).GetAwaiter().GetResult(); + } + + /// + /// Asynchronously streams a large file or specified byte ranges to the client. + /// + /// - If is null or empty, the entire stream is sent with a 200 OK. + /// - If one valid range is specified, a single-range 206 Partial Content response is sent. + /// - If multiple valid ranges are provided, a 206 multipart/byteranges response is generated. + /// - If all provided ranges are invalid, a 416 Range Not Satisfiable is returned. + /// + /// + /// The source to stream from. Must support seeking. + /// + /// + /// The total length of the stream in bytes. + /// + /// + /// Optional list of hashtables defining byte ranges. Each hashtable must contain a Start and End key. + /// Example: @{ Start = 0; End = 1023 } + /// + /// + /// A representing the asynchronous streaming operation. + /// + /// + /// This method disables Pode's request timeout to allow long-running transfers. + /// Responses are chunked or bounded depending on the range(s) and HTTP semantics. + /// + public async Task WriteLargeStream(Stream src, long length, long[] ranges = null) + { + if (IsDisposed) return; + // Disable Pode request timeout for long transfers + Context.CancelTimeout(); + + // Parse ranges (if provided) + var parsed = new List<(long Start, long End)>(); + + if (ranges != null) { + if (ranges.Length % 2 != 0) + { + throw new ArgumentException("Ranges must be provided as pairs of Start and End values."); + } + for (int i = 0; i < ranges.Length; i += 2) + { + parsed.Add((ranges[i], ranges[i + 1])); + } + + } + // === Full file === + if (parsed.Count == 0) + { + if (ranges == null) + { + ContentLength64 = length; + await SendHeaders(false).ConfigureAwait(false); + await StreamSectionAsync(src, 0, length - 1).ConfigureAwait(false); + SentBody = true; + return; + } + else + { + // Ranges provided but all invalid — return 416 + StatusCode = 416; + Headers.Set("Content-Range", $"bytes */{length}"); + ContentLength64 = 0; + await SendHeaders(false).ConfigureAwait(false); + SentBody = true; + return; + } + } + + // === Single range === + if (parsed.Count == 1) + { + var r = parsed[0]; + StatusCode = 206; + Headers.Set("Content-Range", $"bytes {r.Start}-{r.End}/{length}"); + ContentLength64 = r.End - r.Start + 1; + await SendHeaders(false).ConfigureAwait(false); + await StreamSectionAsync(src, r.Start, r.End).ConfigureAwait(false); + SentBody = true; return; } - if (!(file is FileInfo fileInfo) || !fileInfo.Exists) + // === Multiple ranges === + string boundary = "pode_" + Guid.NewGuid().ToString("N"); + StatusCode = 206; + ContentType = $"multipart/byteranges; boundary={boundary}"; + Headers.Remove("Content-Length"); + await SendHeaders(false).ConfigureAwait(false); + + foreach (var (Start, End) in parsed) { - throw new FileNotFoundException($"File not found: {file.FullName}"); + string headerPart = string.Concat( + "--", boundary, PodeHelpers.NEW_LINE, + "Content-Type: application/octet-stream", PodeHelpers.NEW_LINE, + $"Content-Range: bytes {Start}-{End}/{length}", PodeHelpers.NEW_LINE, + PodeHelpers.NEW_LINE); + + await Write(System.Text.Encoding.ASCII.GetBytes(headerPart)).ConfigureAwait(false); + await StreamSectionAsync(src, Start, End).ConfigureAwait(false); + await Write(System.Text.Encoding.ASCII.GetBytes(PodeHelpers.NEW_LINE)).ConfigureAwait(false); } - ContentLength64 = fileInfo.Length; - using (var fileStream = fileInfo.OpenRead()) + string footer = string.Concat("--", boundary, "--", PodeHelpers.NEW_LINE); + await Write(System.Text.Encoding.ASCII.GetBytes(footer), flush: true).ConfigureAwait(false); + SentBody = true; + } + + /// + /// Asynchronously streams a contiguous byte section from a source into the request's input stream. + /// + /// + /// The source stream to read from. Must support seeking. + /// + /// + /// The starting byte offset in the source stream from which to begin reading. + /// + /// + /// The ending byte offset (inclusive) in the source stream up to which bytes are read. + /// + /// + /// A representing the asynchronous copy operation. + /// + /// + /// Thrown if is null. + /// + /// + /// Thrown if does not support seeking. + /// + /// + /// This method reads data in 64 KiB blocks and writes it to Request.InputStream. + /// It flushes after each write to ensure data is sent immediately. + /// This operation is cancellation-aware using Context.Listener.CancellationToken. + /// + private async Task StreamSectionAsync(Stream src, long start, long end) + { + if (src == null) + throw new ArgumentNullException(nameof(src)); + + if (!src.CanSeek) + throw new NotSupportedException("Source stream must support Seek."); + + const int BufSize = 64 * 1024; // 64 KiB blocks + byte[] buf = ArrayPool.Shared.Rent(BufSize); + + try { - fileStream.CopyTo(OutputStream); + src.Seek(start, SeekOrigin.Begin); + long remaining = end - start + 1; + + while (remaining > 0) + { + int toRead = (int)Math.Min(BufSize, remaining); + +#if NETCOREAPP2_1_OR_GREATER + int read = await src.ReadAsync(buf.AsMemory(0, toRead), Context.Listener.CancellationToken) + .ConfigureAwait(false); + if (read == 0) break; + remaining -= read; + + await Request.InputStream.WriteAsync(buf.AsMemory(0, read), Context.Listener.CancellationToken) + .ConfigureAwait(false); +#else + int read = await src.ReadAsync(buf, 0, toRead, Context.Listener.CancellationToken) + .ConfigureAwait(false); + if (read == 0) break; + remaining -= read; + + await Request.InputStream.WriteAsync(buf, 0, read, Context.Listener.CancellationToken) + .ConfigureAwait(false); +#endif + await Request.InputStream.FlushAsync(Context.Listener.CancellationToken) + .ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(buf); } } + #endregion + + /// + /// Sets default headers for the HTTP response. + /// This method ensures that the response has the necessary headers such as Content-Length, Date, Server, and X-Pode-ContextId. + /// private void SetDefaultHeaders() { // ensure content length (remove for 1xx responses, ensure added otherwise) - if (StatusCode < 200 || SseEnabled) + if (StatusCode < 200 || SseEnabled || SendChunked) { Headers.Remove("Content-Length"); } @@ -483,6 +1116,12 @@ private void SetDefaultHeaders() } } + /// + /// Builds the HTTP response headers as a string. + /// This method constructs the response headers from the PodeResponseHeaders object, + /// + /// + /// private string BuildHeaders(PodeResponseHeaders headers) { var builder = new StringBuilder(); @@ -500,22 +1139,43 @@ private string BuildHeaders(PodeResponseHeaders headers) return builder.ToString(); } + /// + /// Disposes the response object and releases its resources. + /// This method is called to clean up the response when it is no longer needed. + /// It ensures that any managed resources, such as the output stream, are properly disposed of. + /// public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes managed and optionally unmanaged resources. + /// This method is called by the Dispose method and can be overridden in derived classes to release additional resources. + /// It checks if the object has already been disposed to avoid multiple disposals. + /// + /// Whether managed resources should be disposed. + protected virtual void Dispose(bool disposing) { if (IsDisposed) - { return; - } - IsDisposed = true; - - if (OutputStream != default(MemoryStream)) + if (disposing) { - OutputStream.Dispose(); - OutputStream = default; + // free managed resources + if (OutputStream != null) + { + OutputStream.Dispose(); + OutputStream = null; + } } + // no unmanaged resources to free + PodeHelpers.WriteErrorMessage($"Response disposed", Context.Listener, PodeLoggingLevel.Verbose, Context); + IsDisposed = true; } + } } \ No newline at end of file diff --git a/src/Listener/PodeResponseHeaders.cs b/src/Listener/PodeResponseHeaders.cs index e619a5a86..8121b95f6 100644 --- a/src/Listener/PodeResponseHeaders.cs +++ b/src/Listener/PodeResponseHeaders.cs @@ -7,7 +7,7 @@ public class PodeResponseHeaders { public object this[string name] { - get => (Headers.ContainsKey(name) ? Headers[name][0] : string.Empty); + get => Headers.TryGetValue(name, out IList value) ? value[0] : string.Empty; set => Set(name, value); } @@ -54,10 +54,7 @@ public void Add(string name, object value) public void Remove(string name) { - if (Headers.ContainsKey(name)) - { - Headers.Remove(name); - } + Headers.Remove(name); } public void Clear() diff --git a/src/Pode.psd1 b/src/Pode.psd1 index aff2bfcb7..6f1c945a3 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -57,6 +57,7 @@ 'Set-PodeHeader', 'Set-PodeHeaderBulk', 'Test-PodeHeaderSigned', + 'Remove-PodeHeader', # state 'Set-PodeState', @@ -169,6 +170,8 @@ 'Test-PodeRoute', 'Test-PodeStaticRoute', 'Test-PodeSignalRoute', + 'Add-PodeRouteCache', + 'Add-PodeRouteCompression', # handlers 'Add-PodeHandler', @@ -533,7 +536,15 @@ 'New-PodeLimitRouteComponent', 'New-PodeLimitEndpointComponent', 'New-PodeLimitMethodComponent', - 'New-PodeLimitHeaderComponent' + 'New-PodeLimitHeaderComponent', + + # Mime Types + 'Add-PodeMimeType', + 'Set-PodeMimeType', + 'Remove-PodeMimeType', + 'Get-PodeMimeType', + 'Test-PodeMimeType', + 'Import-PodeMimeTypeFromFile' ) # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index 19d4c1004..95a9f43aa 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -481,11 +481,6 @@ function New-PodeContext { $ctx.Server.Middleware = @() $ctx.Server.BodyParsers = @{} - # common support values - $ctx.Server.Compression = @{ - Encodings = @('gzip', 'deflate', 'x-gzip') - } - # endware that needs to run $ctx.Server.Endware = @() @@ -943,6 +938,9 @@ function Open-PodeConfiguration { Set-PodeServerConfiguration -Configuration $config.Server -Context $Context Set-PodeWebConfiguration -Configuration $config.Web -Context $Context } + else { + Set-PodeWebConfiguration -Configuration @{} -Context $Context + } # Return the loaded configuration return $config @@ -1121,13 +1119,23 @@ function Set-PodeWebConfiguration { Routes = @{} } Compression = @{ - Enabled = [bool]$Configuration.Compression.Enable + Enabled = [bool]$Configuration.Compression.Enable + Encodings = $(if ($PSVersionTable.PSEdition -eq 'Core') { @('gzip', 'deflate', 'br', 'x-gzip') } else { @('gzip', 'deflate', 'br') }) } OpenApi = @{ DefaultDefinitionTag = [string](Protect-PodeValue -Value $Configuration.OpenApi.DefaultDefinitionTag -Default 'default') } } + if ($Configuration.Compression) { + if ( $Configuration.Compression.ContainsKey('Enabled')) { + $Context.Server.Web.Compression.Enabled = [bool]$Configuration.Compression.Enabled + } + if ( $Configuration.Compression.ContainsKey('Encodings')) { + $Context.Server.Web.Compression.Encodings = @($Configuration.Compression.Encodings) + } + } + if ($Configuration.OpenApi -and $Configuration.OpenApi.ContainsKey('UsePodeYamlInternal')) { $Context.Server.Web.OpenApi.UsePodeYamlInternal = $Configuration.OpenApi.UsePodeYamlInternal } diff --git a/src/Private/Headers.ps1 b/src/Private/Headers.ps1 new file mode 100644 index 000000000..342e6084b --- /dev/null +++ b/src/Private/Headers.ps1 @@ -0,0 +1,359 @@ + +function ConvertFrom-PodeHeaderQValue { + param( + [Parameter()] + [string] + $Value + ) + + process { + $qs = [ordered]@{} + + # return if no value + if ([string]::IsNullOrWhiteSpace($Value)) { + return $qs + } + + # split the values up + $parts = @($Value -isplit ',').Trim() + + # go through each part and check its q-value + foreach ($part in $parts) { + # default of 1 if no q-value + if ($part.IndexOf(';q=') -eq -1) { + $qs[$part] = 1.0 + continue + } + + # parse for q-value + $atoms = @($part -isplit ';q=') + $qs[$atoms[0]] = [double]$atoms[1] + } + + return $qs + } +} + + +<# +.SYNOPSIS + Resolves the most appropriate compression encoding for a Pode route based on Accept-Encoding or Content-Encoding headers. + +.DESCRIPTION + This function determines the best compression encoding to use for a given route by evaluating the Accept-Encoding or Content-Encoding HTTP headers. + It supports quality (q) values and prioritizes encodings based on client preference and route configuration. + If no suitable encoding is found, it can optionally throw an HTTP 406 error. + +.PARAMETER Route + The route hashtable containing compression configuration. + +.PARAMETER AcceptEncoding + The Accept-Encoding header value from the client request. Used to negotiate response compression. + +.PARAMETER ContentEncoding + The Content-Encoding header value from the client request. Used to negotiate request decompression. + +.PARAMETER ThrowError + If specified, throws an HTTP 406 error when no acceptable encoding is found. + +.OUTPUTS + System.String + Returns the resolved encoding name as a string, or an empty string if no encoding is selected. + +.EXAMPLE + Resolve-PodeCompressionEncoding -AcceptEncoding 'gzip,deflate' -Route $Route + +.EXAMPLE + Resolve-PodeCompressionEncoding -ContentEncoding 'gzip' -Route $Route + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Resolve-PodeCompressionEncoding { + [CmdletBinding(DefaultParameterSetName = 'AcceptEncoding')] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [Hashtable] + $Route, + + [Parameter(mandatory = $true, ParameterSetName = 'AcceptEncoding')] + [allowemptystring()] + [string] + $AcceptEncoding, + + [Parameter(mandatory = $true, ParameterSetName = 'ContentEncoding')] + [allowemptystring()] + [String] + $ContentEncoding, + + [switch] + $ThrowError + ) + # return empty if compression is not enabled + if (!$Route.Compression.Enabled -or $Route.Compression.Encodings.Count -eq 0) { + return [string]::Empty + } + + if ($PSCmdlet.ParameterSetName -ieq 'ContentEncoding') { + + if ([string]::IsNullOrWhiteSpace($ContentEncoding) -or !$Route.Compression.Request) { + return [string]::Empty + } + # convert encoding form q-form + $encodings = ConvertFrom-PodeHeaderQValue -Value $ContentEncoding + } + elseif ($PSCmdlet.ParameterSetName -ieq 'AcceptEncoding') { + if ([string]::IsNullOrWhiteSpace($AcceptEncoding) -or !$Route.Compression.Response) { + return [string]::Empty + } + # convert encoding form q-form + $encodings = ConvertFrom-PodeHeaderQValue -Value $AcceptEncoding + } + + if ($encodings.Count -eq 0) { + return [string]::Empty + } + + # check the encodings for one that matches + $normal = @('identity', '*') + $valid = @() + + # build up supported and invalid + foreach ($encoding in $encodings.Keys) { + if (($encoding -iin $Route.Compression.Encodings) -or ($encoding -iin $normal)) { + $valid += @{ + Name = $encoding + Value = $encodings[$encoding] + } + } + } + + # if it's empty, just return empty + if ($valid.Length -eq 0) { + return [string]::Empty + } + + # find the highest ranked match + $found = @{} + $failOnIdentity = $false + + foreach ($encoding in $valid) { + if ($encoding.Value -gt $found.Value) { + $found = $encoding + } + + if (!$failOnIdentity -and ($encoding.Value -eq 0) -and ($encoding.Name -iin $normal)) { + $failOnIdentity = $true + } + } + + # force found to identity/* if the 0 is not identity - meaning it's still allowed + if (($found.Value -eq 0) -and !$failOnIdentity) { + $found = @{ + Name = 'identity' + Value = 1.0 + } + } + + # return invalid, error, or return empty for idenity? + if ($found.Value -eq 0) { + if ($ThrowError) { + throw (New-PodeRequestException -StatusCode 406) + } + } + + # else, we're safe + if ($found.Name -iin $normal) { + return [string]::Empty + } + + if ($found.Name -ieq 'x-gzip') { + return 'gzip' + } + + return $found.Name +} + + + +<# +.SYNOPSIS + Parses a range string and converts it into a hashtable array of start and end values. + +.DESCRIPTION + This function takes a range string (typically used in HTTP headers) and extracts the relevant start and end values. It supports the 'bytes' unit and handles multiple ranges separated by commas. + +.PARAMETER Range + The range string to parse. + +.PARAMETER ThrowError + A switch parameter. If specified, the function throws an exception (HTTP status code 416) when encountering invalid range formats. + +.OUTPUTS + An array of hashtables, each containing 'Start' and 'End' properties representing the parsed ranges. + +.EXAMPLE + Get-PodeRange -Range 'bytes=100-200,300-400' + # Returns an array of hashtables: + # [ + # @{ + # Start = 100 + # End = 200 + # }, + # @{ + # Start = 300 + # End = 400 + # } + # ] + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Get-PodeRange { + [CmdletBinding()] + [OutputType([long[]])] + param( + [Parameter()] + [string] + $Range, + + [switch] + $ThrowError + ) + + # return if no ranges + if ([string]::IsNullOrWhiteSpace($Range)) { + return $null + } + + # split on '=' + $parts = @($Range -isplit '=').Trim() + if (($parts.Length -le 1) -or ([string]::IsNullOrWhiteSpace($parts[1]))) { + return $null + } + + $unit = $parts[0] + if ($unit -ine 'bytes') { + if ($ThrowError) { + throw (New-PodeRequestException -StatusCode 416) + } + + return $null + } + + # split on ',' + $parts = @($parts[1] -isplit ',').Trim() + + # parse into From-To hashtable array + $ranges = [long[]]@() + + foreach ($atom in $parts) { + if ($atom -inotmatch '(?[\d]+){0,1}\s?\-\s?(?[\d]+){0,1}') { + if ($ThrowError) { + throw (New-PodeRequestException -StatusCode 416) + } + + return $null + } + $ranges += [long]$Matches['start'] + $ranges += [long]$Matches['end'] + + + } + + return $ranges +} + +function Get-PodeTransferEncoding { + param( + [Parameter()] + [string] + $TransferEncoding, + + [switch] + $ThrowError + ) + + # return if no encoding + if ([string]::IsNullOrWhiteSpace($TransferEncoding)) { + return [string]::Empty + } + + # convert encoding form q-form + $encodings = ConvertFrom-PodeHeaderQValue -Value $TransferEncoding + if ($encodings.Count -eq 0) { + return [string]::Empty + } + + # check the encodings for one that matches + $normal = @('chunked', 'identity') + $invalid = @() + + # if we see a supported one, return immediately. else build up invalid one + foreach ($encoding in $encodings.Keys) { + if ($encoding -iin $PodeContext.Server.Web.Compression.Encodings) { + if ($encoding -ieq 'x-gzip') { + return 'gzip' + } + + return $encoding + } + + if ($encoding -iin $normal) { + continue + } + + $invalid += $encoding + } + + # if we have any invalid, throw a 415 error + if ($invalid.Length -gt 0) { + if ($ThrowError) { + throw (New-PodeRequestException -StatusCode 415) + } + + return $invalid[0] + } + + # else, we're safe + return [string]::Empty +} + +<# +.SYNOPSIS + Extracts the base MIME type from a Content-Type string that may include additional parameters. + +.DESCRIPTION + This function takes a Content-Type string as input and returns only the base MIME type by splitting the string at the semicolon (';') and trimming any excess whitespace. + It is useful for handling HTTP headers or other contexts where Content-Type strings include parameters like charset, boundary, etc. + +.PARAMETER ContentType + The Content-Type string from which to extract the base MIME type. This string can include additional parameters separated by semicolons. + +.EXAMPLE + Split-PodeContentType -ContentType "text/html; charset=UTF-8" + + This example returns 'text/html', stripping away the 'charset=UTF-8' parameter. + +.EXAMPLE + Split-PodeContentType -ContentType "application/json; charset=utf-8" + + This example returns 'application/json', removing the charset parameter. +#> +function Split-PodeContentType { + param( + [Parameter()] + [string] + $ContentType + ) + + # Check if the input string is null, empty, or consists only of whitespace. + if ([string]::IsNullOrWhiteSpace($ContentType)) { + return [string]::Empty # Return an empty string if the input is not valid. + } + + # Split the Content-Type string by the semicolon, which separates the base MIME type from other parameters. + # Trim any leading or trailing whitespace from the resulting MIME type to ensure clean output. + return @($ContentType -isplit ';')[0].Trim() +} \ No newline at end of file diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index beefebb89..59ffc26a0 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -1084,292 +1084,6 @@ function Test-PodeValidNetworkFailure { return ($null -ne $match) } -function ConvertFrom-PodeHeaderQValue { - param( - [Parameter()] - [string] - $Value - ) - - process { - $qs = [ordered]@{} - - # return if no value - if ([string]::IsNullOrWhiteSpace($Value)) { - return $qs - } - - # split the values up - $parts = @($Value -isplit ',').Trim() - - # go through each part and check its q-value - foreach ($part in $parts) { - # default of 1 if no q-value - if ($part.IndexOf(';q=') -eq -1) { - $qs[$part] = 1.0 - continue - } - - # parse for q-value - $atoms = @($part -isplit ';q=') - $qs[$atoms[0]] = [double]$atoms[1] - } - - return $qs - } -} - -function Get-PodeAcceptEncoding { - param( - [Parameter()] - [string] - $AcceptEncoding, - - [switch] - $ThrowError - ) - - # return if no encoding - if ([string]::IsNullOrWhiteSpace($AcceptEncoding)) { - return [string]::Empty - } - - # return empty if not compressing - if (!$PodeContext.Server.Web.Compression.Enabled) { - return [string]::Empty - } - - # convert encoding form q-form - $encodings = ConvertFrom-PodeHeaderQValue -Value $AcceptEncoding - if ($encodings.Count -eq 0) { - return [string]::Empty - } - - # check the encodings for one that matches - $normal = @('identity', '*') - $valid = @() - - # build up supported and invalid - foreach ($encoding in $encodings.Keys) { - if (($encoding -iin $PodeContext.Server.Compression.Encodings) -or ($encoding -iin $normal)) { - $valid += @{ - Name = $encoding - Value = $encodings[$encoding] - } - } - } - - # if it's empty, just return empty - if ($valid.Length -eq 0) { - return [string]::Empty - } - - # find the highest ranked match - $found = @{} - $failOnIdentity = $false - - foreach ($encoding in $valid) { - if ($encoding.Value -gt $found.Value) { - $found = $encoding - } - - if (!$failOnIdentity -and ($encoding.Value -eq 0) -and ($encoding.Name -iin $normal)) { - $failOnIdentity = $true - } - } - - # force found to identity/* if the 0 is not identity - meaning it's still allowed - if (($found.Value -eq 0) -and !$failOnIdentity) { - $found = @{ - Name = 'identity' - Value = 1.0 - } - } - - # return invalid, error, or return empty for idenity? - if ($found.Value -eq 0) { - if ($ThrowError) { - throw (New-PodeRequestException -StatusCode 406) - } - } - - # else, we're safe - if ($found.Name -iin $normal) { - return [string]::Empty - } - - if ($found.Name -ieq 'x-gzip') { - return 'gzip' - } - - return $found.Name -} - -<# -.SYNOPSIS - Parses a range string and converts it into a hashtable array of start and end values. - -.DESCRIPTION - This function takes a range string (typically used in HTTP headers) and extracts the relevant start and end values. It supports the 'bytes' unit and handles multiple ranges separated by commas. - -.PARAMETER Range - The range string to parse. - -.PARAMETER ThrowError - A switch parameter. If specified, the function throws an exception (HTTP status code 416) when encountering invalid range formats. - -.OUTPUTS - An array of hashtables, each containing 'Start' and 'End' properties representing the parsed ranges. - -.EXAMPLE - Get-PodeRange -Range 'bytes=100-200,300-400' - # Returns an array of hashtables: - # [ - # @{ - # Start = 100 - # End = 200 - # }, - # @{ - # Start = 300 - # End = 400 - # } - # ] - -.NOTES - This is an internal function and may change in future releases of Pode. -#> -function Get-PodeRange { - [CmdletBinding()] - [OutputType([hashtable[]])] - param( - [Parameter()] - [string] - $Range, - - [switch] - $ThrowError - ) - - # return if no ranges - if ([string]::IsNullOrWhiteSpace($Range)) { - return $null - } - - # split on '=' - $parts = @($Range -isplit '=').Trim() - if (($parts.Length -le 1) -or ([string]::IsNullOrWhiteSpace($parts[1]))) { - return $null - } - - $unit = $parts[0] - if ($unit -ine 'bytes') { - if ($ThrowError) { - throw (New-PodeRequestException -StatusCode 416) - } - - return $null - } - - # split on ',' - $parts = @($parts[1] -isplit ',').Trim() - - # parse into From-To hashtable array - $ranges = @() - - foreach ($atom in $parts) { - if ($atom -inotmatch '(?[\d]+){0,1}\s?\-\s?(?[\d]+){0,1}') { - if ($ThrowError) { - throw (New-PodeRequestException -StatusCode 416) - } - - return $null - } - - $ranges += @{ - Start = $Matches['start'] - End = $Matches['end'] - } - } - - return $ranges -} - -function Get-PodeTransferEncoding { - param( - [Parameter()] - [string] - $TransferEncoding, - - [switch] - $ThrowError - ) - - # return if no encoding - if ([string]::IsNullOrWhiteSpace($TransferEncoding)) { - return [string]::Empty - } - - # convert encoding form q-form - $encodings = ConvertFrom-PodeHeaderQValue -Value $TransferEncoding - if ($encodings.Count -eq 0) { - return [string]::Empty - } - - # check the encodings for one that matches - $normal = @('chunked', 'identity') - $invalid = @() - - # if we see a supported one, return immediately. else build up invalid one - foreach ($encoding in $encodings.Keys) { - if ($encoding -iin $PodeContext.Server.Compression.Encodings) { - if ($encoding -ieq 'x-gzip') { - return 'gzip' - } - - return $encoding - } - - if ($encoding -iin $normal) { - continue - } - - $invalid += $encoding - } - - # if we have any invalid, throw a 415 error - if ($invalid.Length -gt 0) { - if ($ThrowError) { - throw (New-PodeRequestException -StatusCode 415) - } - - return $invalid[0] - } - - # else, we're safe - return [string]::Empty -} - -function Get-PodeEncodingFromContentType { - param( - [Parameter()] - [string] - $ContentType - ) - - if ([string]::IsNullOrWhiteSpace($ContentType)) { - return [System.Text.Encoding]::UTF8 - } - - $parts = @($ContentType -isplit ';').Trim() - - foreach ($part in $parts) { - if ($part.StartsWith('charset')) { - return [System.Text.Encoding]::GetEncoding(($part -isplit '=')[1].Trim()) - } - } - - return [System.Text.Encoding]::UTF8 -} function New-PodeRequestException { param( @@ -1507,7 +1221,12 @@ function ConvertFrom-PodeRequestContent { [Parameter()] [string] - $TransferEncoding + $TransferEncoding, + + [Parameter()] + [ValidateSet('', 'gzip', 'deflate', 'br')] + [string] + $ContentEncoding ) # get the requests content type @@ -1518,7 +1237,6 @@ function ConvertFrom-PodeRequestContent { Data = @{} Files = @{} } - # if there is no content-type then do nothing if ([string]::IsNullOrWhiteSpace($ContentType)) { return $Result @@ -1542,6 +1260,10 @@ function ConvertFrom-PodeRequestContent { # if the request is compressed, attempt to uncompress it if (![string]::IsNullOrWhiteSpace($TransferEncoding)) { $Content = [PodeHelpers]::DecompressBytes($Request.RawBody, $TransferEncoding, $Request.ContentEncoding) + $Result.decompressedBody = $content + }elseif (![string]::IsNullOrWhiteSpace($ContentEncoding)) { + $Content = [PodeHelpers]::DecompressBytes($Request.RawBody, $ContentEncoding, $Request.ContentEncoding) + $Result.decompressedBody = $content } else { $Content = $Request.Body @@ -1561,10 +1283,9 @@ function ConvertFrom-PodeRequestContent { return $Result } } - # run action for the content type switch ($ContentType) { - { $_ -ilike '*/json' } { + { $_ -ilike '*/json' } { if (Test-PodeIsPSCore) { $Result.Data = ($Content | ConvertFrom-Json -AsHashtable) } @@ -1582,6 +1303,7 @@ function ConvertFrom-PodeRequestContent { } { $_ -ilike '*/x-www-form-urlencoded' } { + # parse x-www-form-urlencoded data $Result.Data = (ConvertFrom-PodeNameValueToHashTable -Collection ([System.Web.HttpUtility]::ParseQueryString($Content))) } @@ -1632,43 +1354,6 @@ function ConvertFrom-PodeRequestContent { $Content = $null return $Result } -<# -.SYNOPSIS - Extracts the base MIME type from a Content-Type string that may include additional parameters. - -.DESCRIPTION - This function takes a Content-Type string as input and returns only the base MIME type by splitting the string at the semicolon (';') and trimming any excess whitespace. - It is useful for handling HTTP headers or other contexts where Content-Type strings include parameters like charset, boundary, etc. - -.PARAMETER ContentType - The Content-Type string from which to extract the base MIME type. This string can include additional parameters separated by semicolons. - -.EXAMPLE - Split-PodeContentType -ContentType "text/html; charset=UTF-8" - - This example returns 'text/html', stripping away the 'charset=UTF-8' parameter. - -.EXAMPLE - Split-PodeContentType -ContentType "application/json; charset=utf-8" - - This example returns 'application/json', removing the charset parameter. -#> -function Split-PodeContentType { - param( - [Parameter()] - [string] - $ContentType - ) - - # Check if the input string is null, empty, or consists only of whitespace. - if ([string]::IsNullOrWhiteSpace($ContentType)) { - return [string]::Empty # Return an empty string if the input is not valid. - } - - # Split the Content-Type string by the semicolon, which separates the base MIME type from other parameters. - # Trim any leading or trailing whitespace from the resulting MIME type to ensure clean output. - return @($ContentType -isplit ';')[0].Trim() -} function ConvertFrom-PodeNameValueToHashTable { param( diff --git a/src/Private/Mappers.ps1 b/src/Private/Mappers.ps1 index cace51319..d90275146 100644 --- a/src/Private/Mappers.ps1 +++ b/src/Private/Mappers.ps1 @@ -7,640 +7,13 @@ function Get-PodeContentType { [switch] $DefaultIsNull ) - - if ([string]::IsNullOrEmpty($Extension)) { - $Extension = [string]::Empty - } - - if (!$Extension.StartsWith('.')) { - $Extension = ".$($Extension)" - } - - # Sourced from https://github.com/samuelneff/MimeTypeMap - switch ($Extension.ToLowerInvariant()) { - '.323' { return 'text/h323' } - '.3g2' { return 'video/3gpp2' } - '.3gp' { return 'video/3gpp' } - '.3gp2' { return 'video/3gpp2' } - '.3gpp' { return 'video/3gpp' } - '.7z' { return 'application/x-7z-compressed' } - '.aa' { return 'audio/audible' } - '.aac' { return 'audio/aac' } - '.aaf' { return 'application/octet-stream' } - '.aax' { return 'audio/vnd.audible.aax' } - '.ac3' { return 'audio/ac3' } - '.aca' { return 'application/octet-stream' } - '.accda' { return 'application/msaccess.addin' } - '.accdb' { return 'application/msaccess' } - '.accdc' { return 'application/msaccess.cab' } - '.accde' { return 'application/msaccess' } - '.accdr' { return 'application/msaccess.runtime' } - '.accdt' { return 'application/msaccess' } - '.accdw' { return 'application/msaccess.webapplication' } - '.accft' { return 'application/msaccess.ftemplate' } - '.acx' { return 'application/internet-property-stream' } - '.addin' { return 'application/xml' } - '.ade' { return 'application/msaccess' } - '.adobebridge' { return 'application/x-bridge-url' } - '.adp' { return 'application/msaccess' } - '.adt' { return 'audio/vnd.dlna.adts' } - '.adts' { return 'audio/aac' } - '.afm' { return 'application/octet-stream' } - '.ai' { return 'application/postscript' } - '.aif' { return 'audio/aiff' } - '.aifc' { return 'audio/aiff' } - '.aiff' { return 'audio/aiff' } - '.air' { return 'application/vnd.adobe.air-application-installer-package+zip' } - '.amc' { return 'application/mpeg' } - '.anx' { return 'application/annodex' } - '.apk' { return 'application/vnd.android.package-archive' } - '.application' { return 'application/x-ms-application' } - '.art' { return 'image/x-jg' } - '.asa' { return 'application/xml' } - '.asax' { return 'application/xml' } - '.ascx' { return 'application/xml' } - '.asd' { return 'application/octet-stream' } - '.asf' { return 'video/x-ms-asf' } - '.ashx' { return 'application/xml' } - '.asi' { return 'application/octet-stream' } - '.asm' { return 'text/plain' } - '.asmx' { return 'application/xml' } - '.aspx' { return 'application/xml' } - '.asr' { return 'video/x-ms-asf' } - '.asx' { return 'video/x-ms-asf' } - '.atom' { return 'application/atom+xml' } - '.au' { return 'audio/basic' } - '.avi' { return 'video/x-msvideo' } - '.axa' { return 'audio/annodex' } - '.axs' { return 'application/olescript' } - '.axv' { return 'video/annodex' } - '.bas' { return 'text/plain' } - '.bcpio' { return 'application/x-bcpio' } - '.bin' { return 'application/octet-stream' } - '.bmp' { return 'image/bmp' } - '.c' { return 'text/plain' } - '.cab' { return 'application/octet-stream' } - '.caf' { return 'audio/x-caf' } - '.calx' { return 'application/vnd.ms-office.calx' } - '.cat' { return 'application/vnd.ms-pki.seccat' } - '.cc' { return 'text/plain' } - '.cd' { return 'text/plain' } - '.cdda' { return 'audio/aiff' } - '.cdf' { return 'application/x-cdf' } - '.cer' { return 'application/x-x509-ca-cert' } - '.cfg' { return 'text/plain' } - '.chm' { return 'application/octet-stream' } - '.class' { return 'application/x-java-applet' } - '.clp' { return 'application/x-msclip' } - '.cmd' { return 'text/plain' } - '.cmx' { return 'image/x-cmx' } - '.cnf' { return 'text/plain' } - '.cod' { return 'image/cis-cod' } - '.config' { return 'application/xml' } - '.contact' { return 'text/x-ms-contact' } - '.coverage' { return 'application/xml' } - '.cpio' { return 'application/x-cpio' } - '.cpp' { return 'text/plain' } - '.crd' { return 'application/x-mscardfile' } - '.crl' { return 'application/pkix-crl' } - '.crt' { return 'application/x-x509-ca-cert' } - '.cs' { return 'text/plain' } - '.csdproj' { return 'text/plain' } - '.csh' { return 'application/x-csh' } - '.csproj' { return 'text/plain' } - '.css' { return 'text/css' } - '.csv' { return 'text/csv' } - '.cur' { return 'application/octet-stream' } - '.cxx' { return 'text/plain' } - '.dat' { return 'application/octet-stream' } - '.datasource' { return 'application/xml' } - '.dbproj' { return 'text/plain' } - '.dcr' { return 'application/x-director' } - '.def' { return 'text/plain' } - '.deploy' { return 'application/octet-stream' } - '.der' { return 'application/x-x509-ca-cert' } - '.dgml' { return 'application/xml' } - '.dib' { return 'image/bmp' } - '.dif' { return 'video/x-dv' } - '.dir' { return 'application/x-director' } - '.disco' { return 'application/xml' } - '.divx' { return 'video/divx' } - '.dll' { return 'application/x-msdownload' } - '.dll.config' { return 'application/xml' } - '.dlm' { return 'text/dlm' } - '.doc' { return 'application/msword' } - '.docm' { return 'application/vnd.ms-word.document.macroEnabled.12' } - '.docx' { return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } - '.dot' { return 'application/msword' } - '.dotm' { return 'application/vnd.ms-word.template.macroEnabled.12' } - '.dotx' { return 'application/vnd.openxmlformats-officedocument.wordprocessingml.template' } - '.dsp' { return 'application/octet-stream' } - '.dsw' { return 'text/plain' } - '.dtd' { return 'application/xml' } - '.dtsconfig' { return 'application/xml' } - '.dv' { return 'video/x-dv' } - '.dvi' { return 'application/x-dvi' } - '.dwf' { return 'drawing/x-dwf' } - '.dwg' { return 'application/acad' } - '.dwp' { return 'application/octet-stream' } - '.dxf' { return 'application/x-dxf' } - '.dxr' { return 'application/x-director' } - '.eml' { return 'message/rfc822' } - '.emz' { return 'application/octet-stream' } - '.eot' { return 'application/vnd.ms-fontobject' } - '.eps' { return 'application/postscript' } - '.etl' { return 'application/etl' } - '.etx' { return 'text/x-setext' } - '.evy' { return 'application/envoy' } - '.exe' { return 'application/octet-stream' } - '.exe.config' { return 'application/xml' } - '.fdf' { return 'application/vnd.fdf' } - '.fif' { return 'application/fractals' } - '.filters' { return 'application/xml' } - '.fla' { return 'application/octet-stream' } - '.flac' { return 'audio/flac' } - '.flr' { return 'x-world/x-vrml' } - '.flv' { return 'video/x-flv' } - '.fsscript' { return 'application/fsharp-script' } - '.fsx' { return 'application/fsharp-script' } - '.generictest' { return 'application/xml' } - '.gif' { return 'image/gif' } - '.gpx' { return 'application/gpx+xml' } - '.group' { return 'text/x-ms-group' } - '.gsm' { return 'audio/x-gsm' } - '.gtar' { return 'application/x-gtar' } - '.gz' { return 'application/x-gzip' } - '.gzip' { return 'application/x-gzip' } - '.h' { return 'text/plain' } - '.hdf' { return 'application/x-hdf' } - '.hdml' { return 'text/x-hdml' } - '.hhc' { return 'application/x-oleobject' } - '.hhk' { return 'application/octet-stream' } - '.hhp' { return 'application/octet-stream' } - '.hlp' { return 'application/winhlp' } - '.hpp' { return 'text/plain' } - '.hqx' { return 'application/mac-binhex40' } - '.hta' { return 'application/hta' } - '.htc' { return 'text/x-component' } - '.htm' { return 'text/html' } - '.html' { return 'text/html' } - '.htt' { return 'text/webviewhtml' } - '.hxa' { return 'application/xml' } - '.hxc' { return 'application/xml' } - '.hxd' { return 'application/octet-stream' } - '.hxe' { return 'application/xml' } - '.hxf' { return 'application/xml' } - '.hxh' { return 'application/octet-stream' } - '.hxi' { return 'application/octet-stream' } - '.hxk' { return 'application/xml' } - '.hxq' { return 'application/octet-stream' } - '.hxr' { return 'application/octet-stream' } - '.hxs' { return 'application/octet-stream' } - '.hxt' { return 'text/html' } - '.hxv' { return 'application/xml' } - '.hxw' { return 'application/octet-stream' } - '.hxx' { return 'text/plain' } - '.i' { return 'text/plain' } - '.ico' { return 'image/x-icon' } - '.ics' { return 'application/octet-stream' } - '.idl' { return 'text/plain' } - '.ief' { return 'image/ief' } - '.iii' { return 'application/x-iphone' } - '.inc' { return 'text/plain' } - '.inf' { return 'application/octet-stream' } - '.ini' { return 'text/plain' } - '.inl' { return 'text/plain' } - '.ins' { return 'application/x-internet-signup' } - '.ipa' { return 'application/x-itunes-ipa' } - '.ipg' { return 'application/x-itunes-ipg' } - '.ipproj' { return 'text/plain' } - '.ipsw' { return 'application/x-itunes-ipsw' } - '.iqy' { return 'text/x-ms-iqy' } - '.isp' { return 'application/x-internet-signup' } - '.ite' { return 'application/x-itunes-ite' } - '.itlp' { return 'application/x-itunes-itlp' } - '.itms' { return 'application/x-itunes-itms' } - '.itpc' { return 'application/x-itunes-itpc' } - '.ivf' { return 'video/x-ivf' } - '.jar' { return 'application/java-archive' } - '.java' { return 'application/octet-stream' } - '.jck' { return 'application/liquidmotion' } - '.jcz' { return 'application/liquidmotion' } - '.jfif' { return 'image/pjpeg' } - '.jnlp' { return 'application/x-java-jnlp-file' } - '.jpb' { return 'application/octet-stream' } - '.jpe' { return 'image/jpeg' } - '.jpeg' { return 'image/jpeg' } - '.jpg' { return 'image/jpeg' } - '.js' { return 'application/javascript' } - '.json' { return 'application/json' } - '.jsx' { return 'text/jscript' } - '.jsxbin' { return 'text/plain' } - '.jwt' { return 'application/jwt' } - '.latex' { return 'application/x-latex' } - '.library-ms' { return 'application/windows-library+xml' } - '.lit' { return 'application/x-ms-reader' } - '.loadtest' { return 'application/xml' } - '.lpk' { return 'application/octet-stream' } - '.lsf' { return 'video/x-la-asf' } - '.lst' { return 'text/plain' } - '.lsx' { return 'video/x-la-asf' } - '.lzh' { return 'application/octet-stream' } - '.m13' { return 'application/x-msmediaview' } - '.m14' { return 'application/x-msmediaview' } - '.m1v' { return 'video/mpeg' } - '.m2t' { return 'video/vnd.dlna.mpeg-tts' } - '.m2ts' { return 'video/vnd.dlna.mpeg-tts' } - '.m2v' { return 'video/mpeg' } - '.m3u' { return 'audio/x-mpegurl' } - '.m3u8' { return 'audio/x-mpegurl' } - '.m4a' { return 'audio/m4a' } - '.m4b' { return 'audio/m4b' } - '.m4p' { return 'audio/m4p' } - '.m4r' { return 'audio/x-m4r' } - '.m4v' { return 'video/x-m4v' } - '.mac' { return 'image/x-macpaint' } - '.mak' { return 'text/plain' } - '.man' { return 'application/x-troff-man' } - '.manifest' { return 'application/x-ms-manifest' } - '.map' { return 'text/plain' } - '.markdown' { return 'text/markdown' } - '.master' { return 'application/xml' } - '.mbox' { return 'application/mbox' } - '.md' { return 'text/markdown' } - '.mda' { return 'application/msaccess' } - '.mdb' { return 'application/x-msaccess' } - '.mde' { return 'application/msaccess' } - '.mdp' { return 'application/octet-stream' } - '.me' { return 'application/x-troff-me' } - '.mfp' { return 'application/x-shockwave-flash' } - '.mht' { return 'message/rfc822' } - '.mhtml' { return 'message/rfc822' } - '.mid' { return 'audio/mid' } - '.midi' { return 'audio/mid' } - '.mix' { return 'application/octet-stream' } - '.mjs' { return 'application/javascript' } - '.mk' { return 'text/plain' } - '.mk3d' { return 'video/x-matroska-3d' } - '.mka' { return 'audio/x-matroska' } - '.mkv' { return 'video/x-matroska' } - '.mmf' { return 'application/x-smaf' } - '.mno' { return 'application/xml' } - '.mny' { return 'application/x-msmoney' } - '.mod' { return 'video/mpeg' } - '.mov' { return 'video/quicktime' } - '.movie' { return 'video/x-sgi-movie' } - '.mp2' { return 'video/mpeg' } - '.mp2v' { return 'video/mpeg' } - '.mp3' { return 'audio/mpeg' } - '.mp4' { return 'video/mp4' } - '.mp4v' { return 'video/mp4' } - '.mpa' { return 'video/mpeg' } - '.mpe' { return 'video/mpeg' } - '.mpeg' { return 'video/mpeg' } - '.mpf' { return 'application/vnd.ms-mediapackage' } - '.mpg' { return 'video/mpeg' } - '.mpp' { return 'application/vnd.ms-project' } - '.mpv2' { return 'video/mpeg' } - '.mqv' { return 'video/quicktime' } - '.ms' { return 'application/x-troff-ms' } - '.msg' { return 'application/vnd.ms-outlook' } - '.msi' { return 'application/octet-stream' } - '.mso' { return 'application/octet-stream' } - '.mts' { return 'video/vnd.dlna.mpeg-tts' } - '.mtx' { return 'application/xml' } - '.mvb' { return 'application/x-msmediaview' } - '.mvc' { return 'application/x-miva-compiled' } - '.mxp' { return 'application/x-mmxp' } - '.nc' { return 'application/x-netcdf' } - '.nsc' { return 'video/x-ms-asf' } - '.nws' { return 'message/rfc822' } - '.ocx' { return 'application/octet-stream' } - '.oda' { return 'application/oda' } - '.odb' { return 'application/vnd.oasis.opendocument.database' } - '.odc' { return 'application/vnd.oasis.opendocument.chart' } - '.odf' { return 'application/vnd.oasis.opendocument.formula' } - '.odg' { return 'application/vnd.oasis.opendocument.graphics' } - '.odh' { return 'text/plain' } - '.odi' { return 'application/vnd.oasis.opendocument.image' } - '.odl' { return 'text/plain' } - '.odm' { return 'application/vnd.oasis.opendocument.text-master' } - '.odp' { return 'application/vnd.oasis.opendocument.presentation' } - '.ods' { return 'application/vnd.oasis.opendocument.spreadsheet' } - '.odt' { return 'application/vnd.oasis.opendocument.text' } - '.oga' { return 'audio/ogg' } - '.ogg' { return 'audio/ogg' } - '.ogv' { return 'video/ogg' } - '.ogx' { return 'application/ogg' } - '.one' { return 'application/onenote' } - '.onea' { return 'application/onenote' } - '.onepkg' { return 'application/onenote' } - '.onetmp' { return 'application/onenote' } - '.onetoc' { return 'application/onenote' } - '.onetoc2' { return 'application/onenote' } - '.opus' { return 'audio/ogg' } - '.orderedtest' { return 'application/xml' } - '.osdx' { return 'application/opensearchdescription+xml' } - '.otf' { return 'application/font-sfnt' } - '.otg' { return 'application/vnd.oasis.opendocument.graphics-template' } - '.oth' { return 'application/vnd.oasis.opendocument.text-web' } - '.otp' { return 'application/vnd.oasis.opendocument.presentation-template' } - '.ots' { return 'application/vnd.oasis.opendocument.spreadsheet-template' } - '.ott' { return 'application/vnd.oasis.opendocument.text-template' } - '.oxt' { return 'application/vnd.openofficeorg.extension' } - '.p10' { return 'application/pkcs10' } - '.p12' { return 'application/x-pkcs12' } - '.p7b' { return 'application/x-pkcs7-certificates' } - '.p7c' { return 'application/pkcs7-mime' } - '.p7m' { return 'application/pkcs7-mime' } - '.p7r' { return 'application/x-pkcs7-certreqresp' } - '.p7s' { return 'application/pkcs7-signature' } - '.pbm' { return 'image/x-portable-bitmap' } - '.pcast' { return 'application/x-podcast' } - '.pct' { return 'image/pict' } - '.pcx' { return 'application/octet-stream' } - '.pcz' { return 'application/octet-stream' } - '.pdf' { return 'application/pdf' } - '.pfb' { return 'application/octet-stream' } - '.pfm' { return 'application/octet-stream' } - '.pfx' { return 'application/x-pkcs12' } - '.pgm' { return 'image/x-portable-graymap' } - '.pic' { return 'image/pict' } - '.pict' { return 'image/pict' } - '.pkgdef' { return 'text/plain' } - '.pkgundef' { return 'text/plain' } - '.pko' { return 'application/vnd.ms-pki.pko' } - '.pls' { return 'audio/scpls' } - '.pma' { return 'application/x-perfmon' } - '.pmc' { return 'application/x-perfmon' } - '.pml' { return 'application/x-perfmon' } - '.pmr' { return 'application/x-perfmon' } - '.pmw' { return 'application/x-perfmon' } - '.png' { return 'image/png' } - '.pnm' { return 'image/x-portable-anymap' } - '.pnt' { return 'image/x-macpaint' } - '.pntg' { return 'image/x-macpaint' } - '.pnz' { return 'image/png' } - '.pode' { return 'application/PowerShell' } - '.pot' { return 'application/vnd.ms-powerpoint' } - '.potm' { return 'application/vnd.ms-powerpoint.template.macroEnabled.12' } - '.potx' { return 'application/vnd.openxmlformats-officedocument.presentationml.template' } - '.ppa' { return 'application/vnd.ms-powerpoint' } - '.ppam' { return 'application/vnd.ms-powerpoint.addin.macroEnabled.12' } - '.ppm' { return 'image/x-portable-pixmap' } - '.pps' { return 'application/vnd.ms-powerpoint' } - '.ppsm' { return 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12' } - '.ppsx' { return 'application/vnd.openxmlformats-officedocument.presentationml.slideshow' } - '.ppt' { return 'application/vnd.ms-powerpoint' } - '.pptm' { return 'application/vnd.ms-powerpoint.presentation.macroEnabled.12' } - '.pptx' { return 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } - '.prf' { return 'application/pics-rules' } - '.prm' { return 'application/octet-stream' } - '.prx' { return 'application/octet-stream' } - '.ps' { return 'application/postscript' } - '.ps1' { return 'application/PowerShell' } - '.psc1' { return 'application/PowerShell' } - '.psd1' { return 'application/PowerShell' } - '.psm1' { return 'application/PowerShell' } - '.psd' { return 'application/octet-stream' } - '.psess' { return 'application/xml' } - '.psm' { return 'application/octet-stream' } - '.psp' { return 'application/octet-stream' } - '.pst' { return 'application/vnd.ms-outlook' } - '.pub' { return 'application/x-mspublisher' } - '.pwz' { return 'application/vnd.ms-powerpoint' } - '.qht' { return 'text/x-html-insertion' } - '.qhtm' { return 'text/x-html-insertion' } - '.qt' { return 'video/quicktime' } - '.qti' { return 'image/x-quicktime' } - '.qtif' { return 'image/x-quicktime' } - '.qtl' { return 'application/x-quicktimeplayer' } - '.qxd' { return 'application/octet-stream' } - '.ra' { return 'audio/x-pn-realaudio' } - '.ram' { return 'audio/x-pn-realaudio' } - '.rar' { return 'application/x-rar-compressed' } - '.ras' { return 'image/x-cmu-raster' } - '.rat' { return 'application/rat-file' } - '.rc' { return 'text/plain' } - '.rc2' { return 'text/plain' } - '.rct' { return 'text/plain' } - '.rdlc' { return 'application/xml' } - '.reg' { return 'text/plain' } - '.resx' { return 'application/xml' } - '.rf' { return 'image/vnd.rn-realflash' } - '.rgb' { return 'image/x-rgb' } - '.rgs' { return 'text/plain' } - '.rm' { return 'application/vnd.rn-realmedia' } - '.rmi' { return 'audio/mid' } - '.rmp' { return 'application/vnd.rn-rn_music_package' } - '.roff' { return 'application/x-troff' } - '.rpm' { return 'audio/x-pn-realaudio-plugin' } - '.rqy' { return 'text/x-ms-rqy' } - '.rtf' { return 'application/rtf' } - '.rtx' { return 'text/richtext' } - '.rvt' { return 'application/octet-stream' } - '.ruleset' { return 'application/xml' } - '.s' { return 'text/plain' } - '.safariextz' { return 'application/x-safari-safariextz' } - '.scd' { return 'application/x-msschedule' } - '.scr' { return 'text/plain' } - '.sct' { return 'text/scriptlet' } - '.sd2' { return 'audio/x-sd2' } - '.sdp' { return 'application/sdp' } - '.sea' { return 'application/octet-stream' } - '.searchconnector-ms' { return 'application/windows-search-connector+xml' } - '.setpay' { return 'application/set-payment-initiation' } - '.setreg' { return 'application/set-registration-initiation' } - '.settings' { return 'application/xml' } - '.sgimb' { return 'application/x-sgimb' } - '.sgml' { return 'text/sgml' } - '.sh' { return 'application/x-sh' } - '.shar' { return 'application/x-shar' } - '.shtml' { return 'text/html' } - '.sit' { return 'application/x-stuffit' } - '.sitemap' { return 'application/xml' } - '.skin' { return 'application/xml' } - '.skp' { return 'application/x-koan' } - '.sldm' { return 'application/vnd.ms-powerpoint.slide.macroEnabled.12' } - '.sldx' { return 'application/vnd.openxmlformats-officedocument.presentationml.slide' } - '.slk' { return 'application/vnd.ms-excel' } - '.sln' { return 'text/plain' } - '.slupkg-ms' { return 'application/x-ms-license' } - '.smd' { return 'audio/x-smd' } - '.smi' { return 'application/octet-stream' } - '.smx' { return 'audio/x-smd' } - '.smz' { return 'audio/x-smd' } - '.snd' { return 'audio/basic' } - '.snippet' { return 'application/xml' } - '.snp' { return 'application/octet-stream' } - '.sol' { return 'text/plain' } - '.sor' { return 'text/plain' } - '.spc' { return 'application/x-pkcs7-certificates' } - '.spl' { return 'application/futuresplash' } - '.spx' { return 'audio/ogg' } - '.src' { return 'application/x-wais-source' } - '.srf' { return 'text/plain' } - '.ssisdeploymentmanifest' { return 'application/xml' } - '.ssm' { return 'application/streamingmedia' } - '.sst' { return 'application/vnd.ms-pki.certstore' } - '.stl' { return 'application/vnd.ms-pki.stl' } - '.sv4cpio' { return 'application/x-sv4cpio' } - '.sv4crc' { return 'application/x-sv4crc' } - '.svc' { return 'application/xml' } - '.svg' { return 'image/svg+xml' } - '.swf' { return 'application/x-shockwave-flash' } - '.step' { return 'application/step' } - '.stp' { return 'application/step' } - '.t' { return 'application/x-troff' } - '.tar' { return 'application/x-tar' } - '.tcl' { return 'application/x-tcl' } - '.testrunconfig' { return 'application/xml' } - '.testsettings' { return 'application/xml' } - '.tex' { return 'application/x-tex' } - '.texi' { return 'application/x-texinfo' } - '.texinfo' { return 'application/x-texinfo' } - '.tgz' { return 'application/x-compressed' } - '.thmx' { return 'application/vnd.ms-officetheme' } - '.thn' { return 'application/octet-stream' } - '.tif' { return 'image/tiff' } - '.tiff' { return 'image/tiff' } - '.tlh' { return 'text/plain' } - '.tli' { return 'text/plain' } - '.toc' { return 'application/octet-stream' } - '.tr' { return 'application/x-troff' } - '.trm' { return 'application/x-msterminal' } - '.trx' { return 'application/xml' } - '.ts' { return 'video/vnd.dlna.mpeg-tts' } - '.tsv' { return 'text/tab-separated-values' } - '.ttf' { return 'application/font-sfnt' } - '.tts' { return 'video/vnd.dlna.mpeg-tts' } - '.txt' { return 'text/plain' } - '.u32' { return 'application/octet-stream' } - '.uls' { return 'text/iuls' } - '.user' { return 'text/plain' } - '.ustar' { return 'application/x-ustar' } - '.vb' { return 'text/plain' } - '.vbdproj' { return 'text/plain' } - '.vbk' { return 'video/mpeg' } - '.vbproj' { return 'text/plain' } - '.vbs' { return 'text/vbscript' } - '.vcf' { return 'text/x-vcard' } - '.vcproj' { return 'application/xml' } - '.vcs' { return 'text/plain' } - '.vcxproj' { return 'application/xml' } - '.vddproj' { return 'text/plain' } - '.vdp' { return 'text/plain' } - '.vdproj' { return 'text/plain' } - '.vdx' { return 'application/vnd.ms-visio.viewer' } - '.vml' { return 'application/xml' } - '.vscontent' { return 'application/xml' } - '.vsct' { return 'application/xml' } - '.vsd' { return 'application/vnd.visio' } - '.vsi' { return 'application/ms-vsi' } - '.vsix' { return 'application/vsix' } - '.vsixlangpack' { return 'application/xml' } - '.vsixmanifest' { return 'application/xml' } - '.vsmdi' { return 'application/xml' } - '.vspscc' { return 'text/plain' } - '.vss' { return 'application/vnd.visio' } - '.vsscc' { return 'text/plain' } - '.vssettings' { return 'application/xml' } - '.vssscc' { return 'text/plain' } - '.vst' { return 'application/vnd.visio' } - '.vstemplate' { return 'application/xml' } - '.vsto' { return 'application/x-ms-vsto' } - '.vsw' { return 'application/vnd.visio' } - '.vsx' { return 'application/vnd.visio' } - '.vtx' { return 'application/vnd.visio' } - '.wasm' { return 'application/wasm' } - '.wav' { return 'audio/wav' } - '.wave' { return 'audio/wav' } - '.wax' { return 'audio/x-ms-wax' } - '.wbk' { return 'application/msword' } - '.wbmp' { return 'image/vnd.wap.wbmp' } - '.wcm' { return 'application/vnd.ms-works' } - '.wdb' { return 'application/vnd.ms-works' } - '.wdp' { return 'image/vnd.ms-photo' } - '.webarchive' { return 'application/x-safari-webarchive' } - '.webm' { return 'video/webm' } - '.webp' { return 'image/webp' } - '.webtest' { return 'application/xml' } - '.wiq' { return 'application/xml' } - '.wiz' { return 'application/msword' } - '.wks' { return 'application/vnd.ms-works' } - '.wlmp' { return 'application/wlmoviemaker' } - '.wlpginstall' { return 'application/x-wlpg-detect' } - '.wlpginstall3' { return 'application/x-wlpg3-detect' } - '.wm' { return 'video/x-ms-wm' } - '.wma' { return 'audio/x-ms-wma' } - '.wmd' { return 'application/x-ms-wmd' } - '.wmf' { return 'application/x-msmetafile' } - '.wml' { return 'text/vnd.wap.wml' } - '.wmlc' { return 'application/vnd.wap.wmlc' } - '.wmls' { return 'text/vnd.wap.wmlscript' } - '.wmlsc' { return 'application/vnd.wap.wmlscriptc' } - '.wmp' { return 'video/x-ms-wmp' } - '.wmv' { return 'video/x-ms-wmv' } - '.wmx' { return 'video/x-ms-wmx' } - '.wmz' { return 'application/x-ms-wmz' } - '.woff' { return 'application/font-woff' } - '.woff2' { return 'application/font-woff2' } - '.wpl' { return 'application/vnd.ms-wpl' } - '.wps' { return 'application/vnd.ms-works' } - '.wri' { return 'application/x-mswrite' } - '.wrl' { return 'x-world/x-vrml' } - '.wrz' { return 'x-world/x-vrml' } - '.wsc' { return 'text/scriptlet' } - '.wsdl' { return 'application/xml' } - '.wvx' { return 'video/x-ms-wvx' } - '.x' { return 'application/directx' } - '.xaf' { return 'x-world/x-vrml' } - '.xaml' { return 'application/xaml+xml' } - '.xap' { return 'application/x-silverlight-app' } - '.xbap' { return 'application/x-ms-xbap' } - '.xbm' { return 'image/x-xbitmap' } - '.xdr' { return 'text/plain' } - '.xht' { return 'application/xhtml+xml' } - '.xhtml' { return 'application/xhtml+xml' } - '.xla' { return 'application/vnd.ms-excel' } - '.xlam' { return 'application/vnd.ms-excel.addin.macroEnabled.12' } - '.xlc' { return 'application/vnd.ms-excel' } - '.xld' { return 'application/vnd.ms-excel' } - '.xlk' { return 'application/vnd.ms-excel' } - '.xll' { return 'application/vnd.ms-excel' } - '.xlm' { return 'application/vnd.ms-excel' } - '.xls' { return 'application/vnd.ms-excel' } - '.xlsb' { return 'application/vnd.ms-excel.sheet.binary.macroEnabled.12' } - '.xlsm' { return 'application/vnd.ms-excel.sheet.macroEnabled.12' } - '.xlsx' { return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } - '.xlt' { return 'application/vnd.ms-excel' } - '.xltm' { return 'application/vnd.ms-excel.template.macroEnabled.12' } - '.xltx' { return 'application/vnd.openxmlformats-officedocument.spreadsheetml.template' } - '.xlw' { return 'application/vnd.ms-excel' } - '.xml' { return 'application/xml' } - '.xmp' { return 'application/octet-stream' } - '.xmta' { return 'application/xml' } - '.xof' { return 'x-world/x-vrml' } - '.xoml' { return 'text/plain' } - '.xpm' { return 'image/x-xpixmap' } - '.xps' { return 'application/vnd.ms-xpsdocument' } - '.xrm-ms' { return 'application/xml' } - '.xsc' { return 'application/xml' } - '.xsd' { return 'application/xml' } - '.xsf' { return 'application/xml' } - '.xsl' { return 'application/xml' } - '.xslt' { return 'application/xml' } - '.xsn' { return 'application/octet-stream' } - '.xss' { return 'application/xml' } - '.xspf' { return 'application/xspf+xml' } - '.xtp' { return 'application/octet-stream' } - '.xwd' { return 'image/x-xwindowdump' } - '.yaml' { return 'application/yaml' } #RFC 9512 - '.yml' { return 'application/yaml' } - '.z' { return 'application/x-compress' } - '.zip' { return 'application/zip' } - default { return (Resolve-PodeValue -Check $DefaultIsNull -TrueValue $null -FalseValue 'text/plain') } - } + return $(if ($DefaultIsNull) { + [Pode.PodeMimeTypes]::tryGet($Extension, $null ) + } + else { + [Pode.PodeMimeTypes]::Get($Extension) + } + ) } function Get-PodeStatusDescription { diff --git a/src/Private/Middleware.ps1 b/src/Private/Middleware.ps1 index 253bf2f95..2153379aa 100644 --- a/src/Private/Middleware.ps1 +++ b/src/Private/Middleware.ps1 @@ -202,12 +202,8 @@ function Get-PodePublicMiddleware { return $true } - # check current state of caching - $cachable = Test-PodeRouteValidForCaching -Path $WebEvent.Path - # write the file to the response - Write-PodeFileResponse -FileInfo $pubRoute.FileInfo -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge -Cache:$cachable - + Write-PodeFileResponseInternal -FileInfo $pubRoute.FileInfo # public static content found, stop return $false }) @@ -296,6 +292,11 @@ function Get-PodeRouteValidateMiddleware { $WebEvent.TransferEncoding = $route.TransferEncoding } + + $WebEvent.AcceptEncoding = (Resolve-PodeCompressionEncoding -AcceptEncoding (Get-PodeHeader -Name 'Accept-Encoding') -Route $WebEvent.Route -ThrowError) # Accept-Encoding + $WebEvent.ContentEncoding = (Resolve-PodeCompressionEncoding -ContentEncoding (Get-PodeHeader -Name 'Content-Encoding') -Route $WebEvent.Route -ThrowError) # Content-Encoding + $WebEvent.Ranges = (Get-PodeRange -Range (Get-PodeHeader -Name 'Range') -ThrowError) # Range + # set the content type for any pages for the route if it's not empty $WebEvent.ErrorType = $route.ErrorType @@ -309,7 +310,7 @@ function Get-PodeBodyMiddleware { return (Get-PodeInbuiltMiddleware -Name '__pode_mw_body_parsing__' -ScriptBlock { try { # attempt to parse that data - $result = ConvertFrom-PodeRequestContent -Request $WebEvent.Request -ContentType $WebEvent.ContentType -TransferEncoding $WebEvent.TransferEncoding + $result = ConvertFrom-PodeRequestContent -Request $WebEvent.Request -ContentType $WebEvent.ContentType -TransferEncoding $WebEvent.TransferEncoding -ContentEncoding $WebEvent.ContentEncoding # set session data $WebEvent.Data = $result.Data @@ -319,6 +320,7 @@ function Get-PodeBodyMiddleware { return $true } catch { + $_ | write-PodeErrorLog -Level Verbose Set-PodeResponseStatus -Code 400 -Exception $_ return $false } diff --git a/src/Private/PodeServer.ps1 b/src/Private/PodeServer.ps1 index 25cdfb85f..cd2a8f377 100644 --- a/src/Private/PodeServer.ps1 +++ b/src/Private/PodeServer.ps1 @@ -186,9 +186,11 @@ function Start-PodeWebServer { Timestamp = [datetime]::UtcNow TransferEncoding = $null AcceptEncoding = $null + ContentEncoding = $null Ranges = $null Sse = $null Metadata = @{} + Cache = $null } # if iis, and we have an app path, alter it @@ -199,10 +201,7 @@ function Start-PodeWebServer { } } - # accept/transfer encoding $WebEvent.TransferEncoding = (Get-PodeTransferEncoding -TransferEncoding (Get-PodeHeader -Name 'Transfer-Encoding') -ThrowError) - $WebEvent.AcceptEncoding = (Get-PodeAcceptEncoding -AcceptEncoding (Get-PodeHeader -Name 'Accept-Encoding') -ThrowError) - $WebEvent.Ranges = (Get-PodeRange -Range (Get-PodeHeader -Name 'Range') -ThrowError) # add logging endware for post-request Add-PodeRequestLogEndware -WebEvent $WebEvent @@ -238,25 +237,33 @@ function Start-PodeWebServer { throw $Request.Error } + if ($null -ne $WebEvent.Route) { + # set the cache settings for the web event + $WebEvent.Cache = $WebEvent.Route.Cache + } + if ((Invoke-PodeMiddleware -Middleware $WebEvent.Route.Middleware)) { # has the request been aborted if ($Request.IsAborted) { throw $Request.Error } + # invoke the route # invoke the route if ($null -ne $WebEvent.StaticContent) { - $fileBrowser = $WebEvent.Route.FileBrowser - if ($WebEvent.StaticContent.IsDownload) { - Write-PodeAttachmentResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo -FileBrowser:$fileBrowser - } - elseif ($WebEvent.StaticContent.RedirectToDefault) { - $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) - Move-PodeResponseUrl -Url "$($WebEvent.Path)/$($file)" + if ( ('Get', 'Head') -contains $WebEvent.Method) { + $fileBrowser = $WebEvent.Route.FileBrowser + if ($WebEvent.StaticContent.RedirectToDefault) { + $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) + Move-PodeResponseUrl -Url "$($WebEvent.Path)/$($file)" + } + else { + Write-PodeFileResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo ` + -FileBrowser:$fileBrowser -Download:$WebEvent.StaticContent.IsDownload + } } else { - $cachable = $WebEvent.StaticContent.IsCachable - Write-PodeFileResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge -Cache:$cachable -FileBrowser:$fileBrowser + Set-PodeResponseStatus -Code 404 } } elseif ($null -ne $WebEvent.Route.Logic) { diff --git a/src/Private/Responses.ps1 b/src/Private/Responses.ps1 index 8d2a4a3bb..fac22cde7 100644 --- a/src/Private/Responses.ps1 +++ b/src/Private/Responses.ps1 @@ -84,155 +84,6 @@ function Show-PodeErrorPage { Write-PodeFileResponse -Path $errorPage.Path -Data $data -ContentType $errorPage.ContentType } -<# -.SYNOPSIS -Serves files as HTTP responses in a Pode web server, handling both dynamic and static content. - -.DESCRIPTION -This function serves files from the server to the client, supporting both static files and files that are dynamically processed by a view engine. -For dynamic content, it uses the server's configured view engine to process the file and returns the rendered content. -For static content, it simply returns the file's content. The function allows for specifying content type, cache control, and HTTP status code. - -.PARAMETER Path -The relative path to the file to be served. This path is resolved against the server's root directory. - -.PARAMETER FileInfo -A FileSystemInfo object to use instead of the path. - -.PARAMETER Data -A hashtable of data that can be passed to the view engine for dynamic files. - -.PARAMETER ContentType -The MIME type of the response. If not provided, it is inferred from the file extension. - -.PARAMETER MaxAge -The maximum age (in seconds) for which the response can be cached by the client. Applies only to static content. - -.PARAMETER StatusCode -The HTTP status code to accompany the response. Defaults to 200 (OK). - -.PARAMETER Cache -A switch to indicate whether the response should include HTTP caching headers. Applies only to static content. - -.PARAMETER NoEscape -If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. - -.EXAMPLE -Write-PodeFileResponseInternal -Path 'index.pode' -Data @{ Title = 'Home Page' } -ContentType 'text/html' - -Serves the 'index.pode' file as an HTTP response, processing it with the view engine and passing in a title for dynamic content rendering. - -.EXAMPLE -Write-PodeFileResponseInternal -Path 'logo.png' -ContentType 'image/png' -Cache - -Serves the 'logo.png' file as a static file with the specified content type and caching enabled. - -.OUTPUTS -None. The function writes directly to the HTTP response stream. - -.NOTES -This is an internal function and may change in future releases of Pode. -#> -function Write-PodeFileResponseInternal { - [CmdletBinding(DefaultParameterSetName = 'Path')] - param( - [Parameter(Mandatory = $true, ParameterSetName = 'Path')] - [string] - $Path, - - [Parameter(Mandatory = $true, ParameterSetName = 'FileInfo')] - [System.IO.FileSystemInfo] - $FileInfo, - - [Parameter()] - $Data = @{}, - - [Parameter()] - [string] - $ContentType = $null, - - [Parameter()] - [int] - $MaxAge = 3600, - - [Parameter()] - [int] - $StatusCode = 200, - - [switch] - $Cache, - - [switch] - $FileBrowser, - - [switch] - $NoEscape - ) - - # if the file info isn't supplied, get it from the path - if ($null -eq $FileInfo) { - $Path = Protect-PodePath -Path $Path -NoEscape:$NoEscape - $FileInfo = Test-PodePath -Path $Path -Force -ReturnItem -FailOnDirectory:(!$FileBrowser) - } - - # if the file info is still null, return - if ($null -eq $FileInfo) { - return - } - - # Check if the path is a directory, and if enabled, use the directory response function - if ($FileInfo.PSIsContainer) { - Write-PodeDirectoryResponseInternal -DirectoryInfo $FileInfo - return - } - - # are we dealing with a dynamic file for the view engine? (ignore html) - # Determine if the file is dynamic and should be processed by the view engine - $mainExt = $FileInfo.Extension.TrimStart('.') - - # generate dynamic content - if (![string]::IsNullOrEmpty($mainExt) -and ( - ($mainExt -ieq 'pode') -or - ($mainExt -ieq $PodeContext.Server.ViewEngine.Extension -and $PodeContext.Server.ViewEngine.IsDynamic) - ) - ) { - # Process dynamic content with the view engine - $content = Get-PodeFileContentUsingViewEngine -FileInfo $FileInfo -Data $Data - - # Determine the correct content type for the response - # get the sub-file extension, if empty, use original - $subExt = [System.IO.Path]::GetExtension($FileInfo.BaseName).TrimStart('.') - $subExt = Protect-PodeValue -Value $subExt -Default $mainExt - $ContentType = Protect-PodeValue -Value $ContentType -Default (Get-PodeContentType -Extension $subExt) - - # Write the processed content as the HTTP response - Write-PodeTextResponse -Value $content -ContentType $ContentType -StatusCode $StatusCode - return - } - - # this is a static file - try { - # load the file content - $content = [System.IO.File]::ReadAllBytes($FileInfo.FullName) - - # Determine and set the content type for static files - $ContentType = Protect-PodeValue -Value $ContentType -Default (Get-PodeContentType -Extension $mainExt) - - # Write the file content as the HTTP response - Write-PodeTextResponse -Bytes $content -ContentType $ContentType -MaxAge $MaxAge -StatusCode $StatusCode -Cache:$Cache - return - } - catch [System.UnauthorizedAccessException] { - $statusCode = 401 - } - catch { - $statusCode = 400 - } - - # If the file does not exist, set the HTTP response status code appropriately - Set-PodeResponseStatus -Code $StatusCode -} - <# .SYNOPSIS Serves a directory listing as a web page. @@ -387,17 +238,16 @@ function Write-PodeDirectoryResponseInternal { $null = $htmlContent.AppendLine('') } - $Data = @{ + $data = @{ RootPath = $RootPath Path = $leaf.Replace('\', '/') WindowsMode = $windowsMode.ToString().ToLower() FileContent = $htmlContent.ToString() # Convert the StringBuilder content to a string } - $podeRoot = Get-PodeModuleMiscPath + $content = Get-PodeFileContentUsingViewEngine -Path ([System.IO.Path]::Combine((Get-PodeModuleMiscPath), 'default-file-browsing.html.pode')) -Data $data + Write-PodeTextResponse -Value $content -ContentType 'text/html' -StatusCode 200 - # Write the response - Write-PodeFileResponseInternal -Path ([System.IO.Path]::Combine($podeRoot, 'default-file-browsing.html.pode')) -Data $Data -NoEscape } <# @@ -405,7 +255,7 @@ function Write-PodeDirectoryResponseInternal { Sends a file as an attachment in the response, supporting both file streaming and directory browsing options. .DESCRIPTION -The Write-PodeAttachmentResponseInternal function is designed to handle HTTP responses for file downloads or directory browsing within a Pode web server. It resolves the given file or directory path, sets the appropriate content type, and configures the response to either download the file as an attachment or list the directory contents if browsing is enabled. The function supports both PowerShell Core and Windows PowerShell environments for file content retrieval. +The Write-PodeFileResponseInternal function is designed to handle HTTP responses for file downloads or directory browsing within a Pode web server. It resolves the given file or directory path, sets the appropriate content type, and configures the response to either download the file as an attachment or list the directory contents if browsing is enabled. The function supports both PowerShell Core and Windows PowerShell environments for file content retrieval. .PARAMETER Path The path to the file or directory. This parameter is mandatory and accepts pipeline input. The function resolves relative paths based on the server's root directory. @@ -422,13 +272,28 @@ A switch parameter that, when present, enables directory browsing. If the path p .PARAMETER NoEscape If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. +.PARAMETER Data +A hashtable of data that can be passed to the view engine for dynamic files. + +.PARAMETER ContentType +The MIME type of the response. If not provided, it is inferred from the file extension. + +.PARAMETER MaxAge +The maximum age (in seconds) for which the response can be cached by the client. Applies only to static content. + +.PARAMETER StatusCode +The HTTP status code to accompany the response. Defaults to 200 (OK). + +.PARAMETER Cache +A switch to indicate whether the response should include HTTP caching headers. Applies only to static content. + .EXAMPLE -Write-PodeAttachmentResponseInternal -Path './files/document.pdf' -ContentType 'application/pdf' +Write-PodeFileResponseInternal -Path './files/document.pdf' -ContentType 'application/pdf' Serves the 'document.pdf' file with the 'application/pdf' MIME type as a downloadable attachment. .EXAMPLE -Write-PodeAttachmentResponseInternal -Path './files' -FileBrowser +Write-PodeFileResponseInternal -Path './files' -FileBrowser Lists the contents of the './files' directory if the FileBrowser switch is enabled; otherwise, returns a 404 error. @@ -436,7 +301,7 @@ Lists the contents of the './files' directory if the FileBrowser switch is enabl - This function integrates with Pode's internal handling of HTTP responses, leveraging other Pode-specific functions like Get-PodeContentType and Set-PodeResponseStatus. It differentiates between streamed and serverless environments to optimize file delivery. - This is an internal function and may change in future releases of Pode. #> -function Write-PodeAttachmentResponseInternal { +function Write-PodeFileResponseInternal { [CmdletBinding(DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory = $true, ParameterSetName = 'Path')] @@ -447,6 +312,9 @@ function Write-PodeAttachmentResponseInternal { [System.IO.FileSystemInfo] $FileInfo, + [Parameter()] + $Data, + [Parameter()] [string] $ContentType, @@ -456,9 +324,23 @@ function Write-PodeAttachmentResponseInternal { $FileBrowser, [switch] - $NoEscape - ) + $NoEscape, + + [switch] + $Cache, + + [Parameter()] + [int] + $MaxAge = 3600, + [Parameter()] + [switch] + $Download, + + [Parameter()] + [int] + $StatusCode = 200 + ) # if the file info isn't supplied, get it from the path if ($null -eq $FileInfo) { $Path = Protect-PodePath -Path $Path -NoEscape:$NoEscape @@ -476,23 +358,364 @@ function Write-PodeAttachmentResponseInternal { return } - # setup the content type and disposition - if ([string]::IsNullOrEmpty($ContentType)) { - $ContentType = Get-PodeContentType -Extension $FileInfo.Extension - } + # are we dealing with a dynamic file for the view engine? (ignore html) + # Determine if the file is dynamic and should be processed by the view engine + $mainExt = $FileInfo.Extension.TrimStart('.') - $WebEvent.Response.ContentType = $ContentType - Set-PodeHeader -Name 'Content-Disposition' -Value "attachment; filename=$($FileInfo.Name)" + # generate dynamic content + if (![string]::IsNullOrEmpty($mainExt) -and ( + ($mainExt -ieq 'pode') -or + ($mainExt -ieq $PodeContext.Server.ViewEngine.Extension -and $PodeContext.Server.ViewEngine.IsDynamic) + ) + ) { + if ($null -eq $Data) { + $Data = @{} + } + # Process dynamic content with the view engine + $content = Get-PodeFileContentUsingViewEngine -FileInfo $FileInfo -Data $Data + + # Determine the correct content type for the response + # get the sub-file extension, if empty, use original + $subExt = [System.IO.Path]::GetExtension($FileInfo.BaseName).TrimStart('.') + $subExt = Protect-PodeValue -Value $subExt -Default $mainExt + + $ContentType = Protect-PodeValue -Value $ContentType -Default ([Pode.PodeMimeTypes]::Get($subExt)) - # if serverless, get the content raw and return - if (!$WebEvent.Streamed) { - $WebEvent.Response.Body = [System.IO.File]::ReadAllBytes($FileInfo.FullName) + # Write the processed content as the HTTP response + Write-PodeTextResponse -Value $content -ContentType $ContentType -StatusCode $StatusCode return } - # else if normal, stream the content back - $WebEvent.Response.SendChunked = $false + # Determine and set the content type for static files + $ContentType = Protect-PodeValue -Value $ContentType -Default ([Pode.PodeMimeTypes]::Get($mainExt )) + $testualMimeType = [Pode.PodeMimeTypes]::IsTextualMimeType($ContentType) + + if ($testualMimeType) { + if ($Download) { + # If the content type is binary, set it to application/octet-stream + # This is useful for files that should be downloaded rather than displayed + $ContentType = 'application/octet-stream' + } + elseif ($ContentType -notcontains '; charset=') { + # If the content type is textual, ensure it has a charset + $ContentType += "; charset=$($PodeContext.Server.Encoding.WebName)" + } + } + $compression = if ($null -ne $webEvent.Ranges -and $webEvent.Ranges.Count -eq 0) { + [pode.podecompressiontype]::none + } + else { + Set-PodeCompressionType -Length $FileInfo.Length -AcceptEncoding $WebEvent.AcceptEncoding -TestualMimeType $testualMimeType + } + #cache control + try { + if (Set-PodeCacheHeader -WebEventCache $WebEvent.Cache -Cache:$Cache -MaxAge $MaxAge -FileInfo $FileInfo) { + $statusCode = 304 + return + } + + $WebEvent.Response.ContentType = $ContentType + + if ($WebEvent.Method -eq 'Get') { + if ($compression -ne [pode.podecompressiontype]::none) { + Set-PodeHeader -Name 'Content-Encoding' -Value $compression.toString() + } + # set file as an attachment on the response + if ($null -eq $WebEvent.Ranges) { + if ($Download) { + # Set the content disposition to attachment for downloading + # This will prompt the browser to download the file instead of displaying it + # If Download is false, it will be treated as inline + Set-PodeHeader -Name 'Content-Disposition' -Value "attachment; filename=""$($FileInfo.Name)""" + } + else { + # Set the content disposition to inline for viewing in the browser + # This is useful for images, PDFs, etc., that can be displayed directly + # If Download is true, it will be treated as an attachment + Set-PodeHeader -Name 'Content-Disposition' -Value "inline; filename=""$($FileInfo.Name)""" + } + # If the file is not being streamed (Serverless), read the file into the response body + if ($WebEvent.Streamed) { + $WebEvent.Response.WriteFile($FileInfo, $compression) + } + else { + # Read the file into the response body + # This is useful for smaller files that can be loaded into memory + $WebEvent.Response.Body = [System.IO.File]::ReadAllBytes($FileInfo.FullName) + } + } + else { + if ( $WebEvent.Streamed) { + $WebEvent.Response.WriteFile($FileInfo, [long[]]$WebEvent.Ranges, $compression) + } + else { + $start = $WebEvent.Ranges[0] # Offset in bytes + $length = $WebEvent.Ranges[1] - $start + 1 # Number of bytes to read + + $buffer = [byte]::new($length) + $fs = [System.IO.File]::OpenRead($FileInfo.FullName) + + try { + $fs.Seek($start, [System.IO.SeekOrigin]::Begin) | Out-Null + $fs.Read($buffer, 0, $length) | Out-Null + } + finally { + $fs.Dispose() + } + $WebEvent.Response.Body = $buffer + } - # set file as an attachment on the response - $WebEvent.Response.WriteFile($FileInfo) -} \ No newline at end of file + } + } + elseif ($WebEvent.Method -eq 'head') { + Set-PodeHeader -Name 'Content-Length' -Value $FileInfo.Length + } + } + catch [System.UnauthorizedAccessException] { + $statusCode = 401 + } + catch { + write-podehost $_ + $_ | Write-PodeErrorLog -Level Verbose + # If an error occurs, set the HTTP response status code to 400 (Bad Request + $statusCode = 400 + } + finally { + # If the file does not exist, set the HTTP response status code appropriately + Set-PodeResponseStatus -Code $StatusCode + } +} + +<# +.SYNOPSIS + Sets appropriate HTTP cache headers for a response based on route and server settings. + +.DESCRIPTION + This function configures cache-related headers (such as Cache-Control, ETag, and Expires) for HTTP responses. + It determines whether caching should be enabled for the current route and applies the correct headers accordingly. + Used internally by Pode to manage client-side and proxy caching behavior. + +.PARAMETER WebEventCache + A hashtable containing cache settings for the current web event/route, such as visibility, max-age, ETag, etc. + +.PARAMETER Cache + Switch to explicitly enable cache headers for the response. + +.PARAMETER MaxAge + The maximum age (in seconds) for which the response can be cached by the client. Default is 3600. + +.PARAMETER FileInfo + A FileInfo object representing the file being served. Used to determine ETag and last modified time for caching. + +.PARAMETER ETag + An optional ETag value to be set in the response headers. If not provided, it will be generated based on the FileInfo or WebEvent settings. + +.OUTPUTS + Returns $true if cache headers were set (and a 304 should be returned), otherwise $false. + +.EXAMPLE + Set-PodeCacheHeader -WebEventCache $WebEvent.Cache -Cache -MaxAge 600 + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Set-PodeCacheHeader { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingBrokenHashAlgorithms', '')] + param( + [Parameter()] + [Hashtable] + $WebEventCache, + + [Parameter()] + [switch] + $Cache, + + [Parameter()] + [int] + $MaxAge = 3600, + + [Parameter()] + [System.IO.FileInfo] + $FileInfo, + + [Parameter()] + [string] + $ETag + ) + + if ($Cache) { + Set-PodeHeader -Name 'Cache-Control' -Value "max-age=$($MaxAge), must-revalidate" + Set-PodeHeader -Name 'Expires' -Value ([datetime]::UtcNow.AddSeconds($MaxAge).ToString('r', [CultureInfo]::InvariantCulture)) + } + elseif ((Test-PodeRouteValidForCaching -Path $WebEvent.Path ) -and $WebEventCache.Enabled) { + Set-PodeHeader -Name 'Cache-Control' -Value "max-age=$($PodeContext.Server.Web.Static.Cache.MaxAge), must-revalidate" + Set-PodeHeader -Name 'Expires' -Value ([datetime]::UtcNow.AddSeconds($PodeContext.Server.Web.Static.Cache.MaxAge).ToString('r', [CultureInfo]::InvariantCulture)) + } + elseif ($WebEventCache.Enabled) { + $directives = @() + + # Cache visibility (public/private/no-cache/no-store) + if ($WebEventCache.Visibility) { + $directives += $WebEventCache.Visibility + } + + # max-age and s-maxage + if ($WebEventCache.MaxAge -gt 0) { + $directives += "max-age=$($WebEventCache.MaxAge)" + Set-PodeHeader -Name 'Expires' -Value ([datetime]::UtcNow.AddSeconds($WebEventCache.MaxAge).ToString('r', [CultureInfo]::InvariantCulture)) + } + + if ($WebEventCache.SMaxAge -gt 0) { + $directives += "s-maxage=$($WebEventCache.SMaxAge)" + } + + # Must-revalidate + if ($WebEventCache.MustRevalidate) { + $directives += 'must-revalidate' + } + + # Immutable + if ($WebEventCache.Immutable) { + $directives += 'immutable' + } + + # Build and apply Cache-Control + if ($directives.Count -gt 0) { + Set-PodeHeader -Name 'Cache-Control' -Value ($directives -join ', ') + } + # ETag handling + + # if ETag mode is not set to 'None' + if ($WebEventCache.ETag.Mode -ne 'None') { + + # if Etag mode is set to 'manual' but not ETag is provided, return false and do not set ETag header + if ( [string]::IsNullOrEmpty($ETag) -and ($WebEventCache.ETag.Mode -eq 'Manual')) { return $false } + # if ETag mode is set to 'auto' then determine the mode based on the file type + # if the file is static, use mtime, otherwise use hash + $ETagMode = if ($WebEventCache.ETag.Mode -eq 'Auto') { + if ($WebEvent.Route.IsStatic) { + 'Mtime' + } + else { + 'Hash' + } + } + else { + $WebEventCache.ETag.Mode + } + + if ($null -ne $FileInfo) { + # if FileInfo is provided, use it to generate the ETag + # Generate the ETag value based on the mode + switch ($ETagMode) { + 'Mtime' { $value = "$($FileInfo.LastWriteTimeUtc.Ticks)-$($FileInfo.Length)" } + 'Hash' { $value = "$(Get-FileHash -Path $FileInfo.FullName -Algorithm MD5)" } + default { $value = $null } #none + } + if ($value) { + $ETag = if ($WebEventCache.ETag.Weak) { + # if ETag is weak, prefix with W/ + 'W/"{0}"' -f $value + } + else { + '"{0}"' -f $value + } + # Set the ETag header in the response + Set-PodeHeader -Name 'ETag' -Value $etag + + # If the ETag matches the client's cached version, return 304 Not Modified + if ($WebEvent.Request.Headers['If-Modified-Since']) { + try { + $ims = [DateTime]::ParseExact( + $WebEvent.Request.Headers['If-Modified-Since'], + 'r', + [CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal + ) + # If file not modified since client's cached version + if ($FileInfo.LastWriteTimeUtc -le $ims) { + return $true + } + } + catch { + $_ | Write-PodeErrorLoglog -Level Verbose + } + } + } + } + # If the ETag matches the client's cached version, return 304 Not Modified + if ($WebEvent.Request.Headers['If-None-Match'] -eq $ETag) { + return $true + } + } + } + else { + Set-PodeHeader -Name 'Cache-Control' -Value 'no-store, no-cache, must-revalidate' + Set-PodeHeader -Name 'Pragma' -Value 'no-cache' + } + return $false +} + + +<# +.SYNOPSIS + Determines the appropriate compression type for a response based on request headers and content type. + +.DESCRIPTION + This function inspects the 'Accept-Encoding' header of the incoming request and the content type of the response. + It selects the best available compression method (such as gzip, deflate, or Brotli) if the content is textual and large enough. + The function is used internally by Pode to optimize response delivery. + +.PARAMETER Length + The length of the response content in bytes. Compression is only applied if this is greater than 512 bytes. + +.PARAMETER AcceptEncoding + The value of the 'Accept-Encoding' header from the request, indicating supported compression algorithms. + +.PARAMETER TestualMimeType + Indicates whether the response content type is textual and suitable for compression. + +.OUTPUTS + Returns the selected compression type as a [pode.podecompressiontype] enum value (e.g., 'gzip', 'deflate', 'br', or 'none'). + +.EXAMPLE + $compression = Set-PodeCompressionType -Length 2048 -AcceptEncoding $WebEvent.AcceptEncoding -TestualMimeType $true + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Set-PodeCompressionType { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [long] + $Length, + + [Parameter()] + [string] + $AcceptEncoding, + + [Parameter(Mandatory = $true)] + [bool] + $TestualMimeType + ) + $compression = [pode.podecompressiontype]::none + # if the Accept-Encoding header is set, and the mime type is textual, and the length is greater than 512 bytes + # then set the compression type based on the Accept-Encoding header + # Brotli is preferred over gzip, which is preferred over deflate + if (![string]::IsNullOrWhiteSpace($AcceptEncoding) -and $TestualMimeType -and ($Length -gt 512)) { + $encoding = $AcceptEncoding.toLowerInvariant() + switch ($encoding) { + 'br' { $compression = [pode.podecompressiontype]::br; break } + 'brotli' { $compression = [pode.podecompressiontype]::br; break } + 'gzip' { $compression = [pode.podecompressiontype]::gzip; break } + 'gz' { $compression = [pode.podecompressiontype]::gzip; break } + 'deflate' { $compression = [pode.podecompressiontype]::deflate; break } + default { $compression = [pode.podecompressiontype]::none } + } + } + if ($compression -ne [pode.podecompressiontype]::none) { + Set-PodeHeader -Name 'Vary' -Value 'Accept-Encoding' + } + return $compression +} diff --git a/src/Private/Routes.ps1 b/src/Private/Routes.ps1 index 9e49c1b6a..5c5a41a07 100644 --- a/src/Private/Routes.ps1 +++ b/src/Private/Routes.ps1 @@ -605,6 +605,10 @@ function Find-PodeRouteTransferEncoding { # set the default $TransferEncoding = $PodeContext.Server.Web.TransferEncoding.Default + if ( [string]::IsNullOrWhiteSpace($TransferEncoding)) { + return [string]::Empty + } + # find type by pattern from settings $matched = $null foreach ($key in $PodeContext.Server.Web.TransferEncoding.Routes.Keys) { diff --git a/src/Private/Serverless.ps1 b/src/Private/Serverless.ps1 index 8c1c991de..ed704f359 100644 --- a/src/Private/Serverless.ps1 +++ b/src/Private/Serverless.ps1 @@ -86,16 +86,13 @@ function Start-PodeAzFuncServer { # invoke the route if ($null -ne $WebEvent.StaticContent) { $fileBrowser = $WebEvent.Route.FileBrowser - if ($WebEvent.StaticContent.IsDownload) { - Write-PodeAttachmentResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo -FileBrowser:$fileBrowser - } - elseif ($WebEvent.StaticContent.RedirectToDefault) { + if ($WebEvent.StaticContent.RedirectToDefault) { $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) Move-PodeResponseUrl -Url "$($WebEvent.Path)/$($file)" } else { - $cachable = $WebEvent.StaticContent.IsCachable - Write-PodeFileResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge -Cache:$cachable -FileBrowser:$fileBrowser + Write-PodeFileResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo ` + -FileBrowser:$fileBrowser -Download:$WebEvent.StaticContent.IsDownload } } else { @@ -206,16 +203,13 @@ function Start-PodeAwsLambdaServer { # invoke the route if ($null -ne $WebEvent.StaticContent) { $fileBrowser = $WebEvent.Route.FileBrowser - if ($WebEvent.StaticContent.IsDownload) { - Write-PodeAttachmentResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo -FileBrowser:$fileBrowser - } - elseif ($WebEvent.StaticContent.RedirectToDefault) { + if ($WebEvent.StaticContent.RedirectToDefault) { $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) Move-PodeResponseUrl -Url "$($WebEvent.Path)/$($file)" } else { - $cachable = $WebEvent.StaticContent.IsCachable - Write-PodeFileResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge -Cache:$cachable -FileBrowser:$fileBrowser + Write-PodeFileResponseInternal -FileInfo $WebEvent.StaticContent.FileInfo ` + -FileBrowser:$fileBrowser -Download:$WebEvent.StaticContent.IsDownload } } else { diff --git a/src/Public/Headers.ps1 b/src/Public/Headers.ps1 index 84a907b2f..3c75328f3 100644 --- a/src/Public/Headers.ps1 +++ b/src/Public/Headers.ps1 @@ -321,4 +321,24 @@ function Test-PodeHeaderSigned { $header = Get-PodeHeader -Name $Name return Test-PodeValueSigned -Value $header -Secret $Secret -Strict:$Strict +} + +<# +.SYNOPSIS +Removes a header from the Response. +.DESCRIPTION +Removes a header from the Response. If the current context is serverless, then this function removes the key directly; otherwise, it uses the standard removal approach. +.PARAMETER Name +The name of the header to remove. +.EXAMPLE +Remove-PodeHeader -Name 'X-AuthToken' +#> +function Remove-PodeHeader { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + $WebEvent.Response.Headers.Remove($Name) } \ No newline at end of file diff --git a/src/Public/Mime.ps1 b/src/Public/Mime.ps1 new file mode 100644 index 000000000..1e42b7c45 --- /dev/null +++ b/src/Public/Mime.ps1 @@ -0,0 +1,261 @@ +<# +.SYNOPSIS +Add a new MIME type mapping for a file extension. + +.DESCRIPTION +Add a new MIME type mapping for a file extension in the global MIME type registry. +Throws an exception if the extension already exists. + +.PARAMETER Extension +The file extension (with or without leading dot) to map to a MIME type. + +.PARAMETER MimeType +The MIME type to associate with the extension. + +.EXAMPLE +Add-PodeMimeType -Extension '.json' -MimeType 'application/json' + +.EXAMPLE +Add-PodeMimeType -Extension 'xml' -MimeType 'application/xml' +#> +function Add-PodeMimeType { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Extension, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $MimeType + ) + + try { + [Pode.PodeMimeTypes]::Add($Extension, $MimeType) + Write-Verbose "Added MIME type mapping: $Extension -> $MimeType" + } + catch { + $_ | Write-PodeErrorLog + throw $_.Exception + } +} + +<# +.SYNOPSIS +Update an existing MIME type mapping for a file extension. + +.DESCRIPTION +Update an existing MIME type mapping for a file extension. This function will add the mapping +if it doesn't exist, or update it if it does exist. + +.PARAMETER Extension +The file extension (with or without leading dot) to update. + +.PARAMETER MimeType +The new MIME type to associate with the extension. + +.EXAMPLE +Set-PodeMimeType -Extension '.json' -MimeType 'application/vnd.api+json' +#> +function Set-PodeMimeType { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Extension, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $MimeType + ) + try { + [Pode.PodeMimeTypes]::AddOrUpdate($Extension, $MimeType) + Write-Verbose "Updated MIME type mapping: $Extension -> $MimeType" + } + catch { + $_ | Write-PodeErrorLog + throw $_.Exception + } +} + +<# +.SYNOPSIS +Remove a MIME type mapping for a file extension. + +.DESCRIPTION +Remove a MIME type mapping for a file extension from the global MIME type registry. + +.PARAMETER Extension +The file extension (with or without leading dot) to remove from the registry. + +.EXAMPLE +Remove-PodeMimeType -Extension '.myext' + +.EXAMPLE +Remove-PodeMimeType -Extension 'customtype' +Write-Host "MIME type mapping removal attempted" +#> +function Remove-PodeMimeType { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Extension + ) + + try { + $result = [Pode.PodeMimeTypes]::Remove($Extension) + if ($result) { + Write-Verbose "Removed MIME type mapping for extension: $Extension" + } + else { + Write-Verbose "No MIME type mapping found for extension: $Extension" + } + } + catch { + $_ | Write-PodeErrorLog + throw $_.Exception + } +} + +<# +.SYNOPSIS +Get the MIME type for a file extension. + +.DESCRIPTION +Get the MIME type associated with a file extension from the global MIME type registry. + +.PARAMETER Extension +The file extension (with or without leading dot) to look up. + +.PARAMETER DefaultMimeType +The default MIME type to return if the extension is not found. Defaults to 'application/octet-stream'. + +.OUTPUTS +[string] The MIME type associated with the extension, or the default if not found. + +.EXAMPLE +Get-PodeMimeType -Extension '.json' + +.EXAMPLE +$mimeType = Get-PodeMimeType -Extension 'pdf' +Write-Host "PDF MIME type: $mimeType" + +.EXAMPLE +$mimeType = Get-PodeMimeType -Extension '.unknown' -DefaultMimeType 'text/plain' +#> +function Get-PodeMimeType { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Extension, + + [Parameter()] + [string] + $DefaultMimeType = 'application/octet-stream' + ) + + try { + $mimeType = $null + if ([Pode.PodeMimeTypes]::TryGet($Extension, [ref]$mimeType)) { + return $mimeType + } + else { + Write-Verbose "No MIME type found for extension '$Extension', returning default: $DefaultMimeType" + return $DefaultMimeType + } + } + catch { + $_ | Write-PodeErrorLog + throw $_.Exception + } +} + +<# +.SYNOPSIS +Test if a MIME type mapping exists for a file extension. + +.DESCRIPTION +Test if a MIME type mapping exists for a file extension in the global MIME type registry. + +.PARAMETER Extension +The file extension (with or without leading dot) to test. + +.OUTPUTS +[bool] Returns $true if a mapping exists, $false otherwise. + +.EXAMPLE +Test-PodeMimeType -Extension '.json' + +.EXAMPLE +if (Test-PodeMimeType -Extension '.myext') { + Write-Host "Custom extension is registered" +} +#> +function Test-PodeMimeType { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Extension + ) + + try { + return [Pode.PodeMimeTypes]::Contains($Extension) + } + catch { + $_ | Write-PodeErrorLog + throw $_.Exception + } +} + + +<# +.SYNOPSIS +Load MIME type mappings from a file. + +.DESCRIPTION +Bulk-load MIME type mappings from a file in "type ext1 ext2 ..." format (e.g., Apache mime.types list). + +.PARAMETER Path +The path to the file containing MIME type mappings. + +.EXAMPLE +Import-PodeMimeTypeFromFile -Path 'C:\path\to\mime.types' + +.EXAMPLE +Import-PodeMimeTypeFromFile -Path './custom-mime-types.txt' +#> +function Import-PodeMimeTypeFromFile { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Path + ) + + # Validate path exists + if (!(Test-Path $Path)) { + throw ($Podelocale.pathNotExistExceptionMessage -f $Path) + } + + try { + [Pode.PodeMimeTypes]::LoadFromFile($Path) + Write-Verbose "Loaded MIME type mappings from file: $Path" + } + catch { + $_ | Write-PodeErrorLog + throw $_.Exception + } +} + diff --git a/src/Public/OpenApi.ps1 b/src/Public/OpenApi.ps1 index 1f8d5ed77..283dca483 100644 --- a/src/Public/OpenApi.ps1 +++ b/src/Public/OpenApi.ps1 @@ -282,6 +282,10 @@ function Enable-PodeOpenApi { # Set-PodeResponseAttachment -Path Add-PodeHeader -Name 'Content-Disposition' -Value "attachment; filename=openapi.$format" } + else { + # Set-PodeResponseAttachment -Path + Add-PodeHeader -Name 'Content-Disposition' -Value "inline; filename=openapi.$format" + } # generate the openapi definition $def = Get-PodeOpenApiDefinitionInternal ` @@ -292,7 +296,7 @@ function Enable-PodeOpenApi { # write the openapi definition if ($format -ieq 'yaml') { if ($mode -ieq 'view') { - Write-PodeTextResponse -Value (ConvertTo-PodeYaml -InputObject $def -depth $PodeContext.Server.OpenAPI.Definitions[$DefinitionTag].hiddenComponents.depth) -ContentType 'application/yaml; charset=utf-8' #Changed to be RFC 9512 compliant + Write-PodeTextResponse -Value (ConvertTo-PodeYaml -InputObject $def -depth $PodeContext.Server.OpenAPI.Definitions[$DefinitionTag].hiddenComponents.depth) -ContentType 'application/yaml' #Changed to be RFC 9512 compliant } else { Write-PodeYamlResponse -Value $def -Depth $PodeContext.Server.OpenAPI.Definitions[$DefinitionTag].hiddenComponents.depth @@ -1945,8 +1949,8 @@ function Enable-PodeOAViewer { DistPath = $meta.DistPath } - $podeRoot = Get-PodeModuleMiscPath - Write-PodeFileResponseInternal -Path ([System.IO.Path]::Combine($podeRoot, 'default-swagger-editor.html.pode')) -Data $Data + $content = Get-PodeFileContentUsingViewEngine -Path ([System.IO.Path]::Combine((Get-PodeModuleMiscPath), 'default-swagger-editor.html.pode')) -Data $Data + Write-PodeTextResponse -Value $content -ContentType 'text/html' -StatusCode 200 } $PodeContext.Server.OpenAPI.Definitions[$DefinitionTag].hiddenComponents.viewer['editor'] = $Path @@ -1990,8 +1994,8 @@ function Enable-PodeOAViewer { $Data["$($type)_path"] = $PodeContext.Server.OpenAPI.Definitions[$DefinitionTag].hiddenComponents.viewer[$type] } - $podeRoot = Get-PodeModuleMiscPath - Write-PodeFileResponseInternal -Path ([System.IO.Path]::Combine($podeRoot, 'default-doc-bookmarks.html.pode')) -Data $Data + $content = Get-PodeFileContentUsingViewEngine -Path ([System.IO.Path]::Combine((Get-PodeModuleMiscPath), 'default-doc-bookmarks.html.pode')) -Data $Data + Write-PodeTextResponse -Value $content -ContentType 'text/html' -StatusCode 200 } if (! $NoAdvertise.IsPresent) { @@ -2035,15 +2039,16 @@ function Enable-PodeOAViewer { -Role $Role -Scope $Scope -Group $Group ` -ScriptBlock { param($meta) - $podeRoot = Get-PodeModuleMiscPath - if ( $meta.DarkMode) { $Theme = 'dark' } else { $Theme = 'light' } - Write-PodeFileResponseInternal -Path ([System.IO.Path]::Combine($podeRoot, "default-$($meta.Type).html.pode")) -Data @{ + + $data = @{ Title = $meta.Title OpenApi = $meta.OpenApi DarkMode = $meta.DarkMode - Theme = $Theme + Theme = $(if ( $meta.DarkMode) { 'dark' } else { 'light' }) DistPath = $meta.DistPath } + $content = Get-PodeFileContentUsingViewEngine -Path ([System.IO.Path]::Combine((Get-PodeModuleMiscPath), "default-$($meta.Type).html.pode")) -Data $data + Write-PodeTextResponse -Value $content -ContentType 'text/html' -StatusCode 200 } } diff --git a/src/Public/Responses.ps1 b/src/Public/Responses.ps1 index 91faf2f9b..5e23fe0fc 100644 --- a/src/Public/Responses.ps1 +++ b/src/Public/Responses.ps1 @@ -26,6 +26,9 @@ If the path is a folder, instead of returning 404, will return A browsable conte .PARAMETER NoEscape If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. +.PARAMETER Inline +If supplied, the file will be displayed inline in the browser, rather than downloaded as a file. + .EXAMPLE Set-PodeResponseAttachment -Path 'downloads/installer.exe' @@ -66,7 +69,11 @@ function Set-PodeResponseAttachment { $FileBrowser, [switch] - $NoEscape + $NoEscape, + + [Parameter()] + [switch] + $Inline ) begin { $pipelineItemCount = 0 @@ -97,9 +104,8 @@ function Set-PodeResponseAttachment { else { $_path = Get-PodeRelativePath -Path $Path -JoinRoot } - - # call internal Attachment function - Write-PodeAttachmentResponseInternal -Path $_path -ContentType $ContentType -FileBrowser:$fileBrowser -NoEscape + # if the path is a directory, then return a browsable directory response + Write-PodeFileResponseInternal -Path $_path -ContentType $ContentType -FileBrowser:$fileBrowser -NoEscape -Download:(!$Inline.IsPresent) } } @@ -129,6 +135,15 @@ The status code to set against the response. .PARAMETER Cache Should the value be cached by browsers, or not? +.PARAMETER Download +If supplied, the content will be downloaded as a file, rather than displayed in the browser. + +.PARAMETER FileName +The name of the file to download or to visualize in the browser. + +.PARAMETER ETag +An optional ETag value to be set in the response headers. If not provided, it will be generated based on the content. + .EXAMPLE Write-PodeTextResponse -Value 'Leeeeeerrrooooy Jeeeenkiiins!' @@ -164,8 +179,21 @@ function Write-PodeTextResponse { [int] $StatusCode = 200, + [Parameter()] + [switch] + $Download, + + [Parameter()] + [string] + $FileName, + + [Parameter()] [switch] - $Cache + $Cache, + + [Parameter()] + [string] + $ETag ) begin { @@ -184,136 +212,99 @@ function Write-PodeTextResponse { $Value = $pipelineValue -join "`n" } - $isStringValue = ($PSCmdlet.ParameterSetName -ieq 'string') - $isByteValue = ($PSCmdlet.ParameterSetName -ieq 'bytes') - # set the status code of the response, but only if it's not 200 (to prevent overriding) if ($StatusCode -ne 200) { Set-PodeResponseStatus -Code $StatusCode -NoErrorPage } # if there's nothing to write, return - if ($isStringValue -and [string]::IsNullOrEmpty($Value)) { - return - } + if ($PSCmdlet.ParameterSetName -ieq 'string') { - if ($isByteValue -and (($null -eq $Bytes) -or ($Bytes.Length -eq 0))) { - return + if ( [string]::IsNullOrEmpty($Value)) { + return + } + $Bytes = $PodeContext.Server.Encoding.GetBytes($Value) } - - # if the response stream isn't writeable or already sent, return - $res = $WebEvent.Response - if (($null -eq $res) -or ($WebEvent.Streamed -and (($null -eq $res.OutputStream) -or !$res.OutputStream.CanWrite -or $res.Sent))) { + elseif ( ($null -eq $Bytes) -or ($Bytes.Length -eq 0)) { return } + try { + # if the response stream isn't writeable or already sent, return + $res = $WebEvent.Response + if (($null -eq $res) -or ($WebEvent.Streamed -and (($null -eq $res.OutputStream) -or !$res.OutputStream.CanWrite -or $res.Sent))) { + return + } - # set a cache value - if ($Cache) { - Set-PodeHeader -Name 'Cache-Control' -Value "max-age=$($MaxAge), must-revalidate" - Set-PodeHeader -Name 'Expires' -Value ([datetime]::UtcNow.AddSeconds($MaxAge).ToString('r', [CultureInfo]::InvariantCulture)) - } + $testualMimeType = [Pode.PodeMimeTypes]::IsTextualMimeType($ContentType) - # specify the content-type if supplied (adding utf-8 if missing) - if (![string]::IsNullOrEmpty($ContentType)) { - $charset = 'charset=utf-8' - if ($ContentType -inotcontains $charset) { - $ContentType = "$($ContentType); $($charset)" + if ($testualMimeType) { + if ($Download) { + # If the content type is binary, set it to application/octet-stream + # This is useful for files that should be downloaded rather than displayed + $ContentType = 'application/octet-stream' + } + elseif ($ContentType -notcontains '; charset=') { + # If the content type is textual, ensure it has a charset + $ContentType += "; charset=$($PodeContext.Server.Encoding.WebName)" + } } + # set the content type of the response + $WebEvent.Response.ContentType = $ContentType - $res.ContentType = $ContentType - } - - # if we're serverless, set the string as the body - if (!$WebEvent.Streamed) { - if ($isStringValue) { - $res.Body = $Value + # set the compression type based on the Accept-Encoding header and the content length + $compression = if ($null -ne $webEvent.Ranges -and $webEvent.Ranges.Count -eq 0) { + [pode.podecompressiontype]::none } else { - $res.Body = $Bytes + Set-PodeCompressionType -Length $Bytes.Count -AcceptEncoding $WebEvent.AcceptEncoding -TestualMimeType $testualMimeType } - return - } - - # otherwise, write the bytes to the response stream - if ($isStringValue) { - $Bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) - } - - # check if we only need a range of the bytes - if (($null -ne $WebEvent.Ranges) -and ($WebEvent.Response.StatusCode -eq 200) -and ($StatusCode -eq 200)) { - $lengths = @() - $size = $Bytes.Length - - $Bytes = @(foreach ($range in $WebEvent.Ranges) { - # ensure range not invalid - if (([int]$range.Start -lt 0) -or ([int]$range.Start -ge $size) -or ([int]$range.End -lt 0)) { - Set-PodeResponseStatus -Code 416 -NoErrorPage - return - } + # set the cache header if requested + if (Set-PodeCacheHeader -WebEventCache $WebEvent.Cache -Cache:$Cache -MaxAge $MaxAge -ETag $ETag) { + Set-PodeResponseStatus -Code 304 + return + } - # skip start bytes only - if ([string]::IsNullOrEmpty($range.End)) { - $Bytes[$range.Start..($size - 1)] - $lengths += "$($range.Start)-$($size - 1)/$($size)" - } + # if we're serverless, set the string as the body + if (!$WebEvent.Streamed) { + $res.Body = $Bytes + return + } + if ($WebEvent.Method -eq 'Get') { - # end bytes only - elseif ([string]::IsNullOrEmpty($range.Start)) { - if ([int]$range.End -gt $size) { - $range.End = $size - } - - if ([int]$range.End -gt 0) { - $Bytes[$($size - $range.End)..($size - 1)] - $lengths += "$($size - $range.End)-$($size - 1)/$($size)" - } - else { - $lengths += "0-0/$($size)" - } + if ($null -ne $WebEvent.Ranges) { + $WebEvent.Response.WriteBody($Bytes, [long[]] $WebEvent.Ranges, $compression) + return + } + elseif (![string]::IsNullOrEmpty($FileName)) { + if ($Download) { + # Set the content disposition to attachment for downloading + # This will prompt the browser to download the file instead of displaying it + # If Download is false, it will be treated as inline + Set-PodeHeader -Name 'Content-Disposition' -Value "attachment; filename=""$($FileName)""" } - - # normal range else { - if ([int]$range.End -ge $size) { - Set-PodeResponseStatus -Code 416 -NoErrorPage - return - } - - $Bytes[$range.Start..$range.End] - $lengths += "$($range.Start)-$($range.End)/$($size)" + # Set the content disposition to inline for viewing in the browser + # This is useful for images, PDFs, etc., that can be displayed directly + # If Download is true, it will be treated as an attachment + Set-PodeHeader -Name 'Content-Disposition' -Value "inline; filename=""$($FileName)""" } - }) - - Set-PodeHeader -Name 'Content-Range' -Value "bytes $($lengths -join ', ')" - if ($StatusCode -eq 200) { - Set-PodeResponseStatus -Code 206 -NoErrorPage + } } - } - - # check if we need to compress the response - if ($PodeContext.Server.Web.Compression.Enabled -and ![string]::IsNullOrWhiteSpace($WebEvent.AcceptEncoding)) { - # compress the bytes - $Bytes = [PodeHelpers]::CompressBytes($Bytes, $WebEvent.AcceptEncoding) - - # set content encoding header - Set-PodeHeader -Name 'Content-Encoding' -Value $WebEvent.AcceptEncoding - } - - # write the content to the response stream - $res.ContentLength64 = $Bytes.Length - - try { - $res.OutputStream.Write($Bytes, 0, $Bytes.Length) + if ($compression -ne [pode.podecompressiontype]::none) { + Set-PodeHeader -Name 'Content-Encoding' -Value $compression.toString() + } + # write the content to the response stream + $WebEvent.Response.WriteBody($Bytes, $compression) } catch { if (Test-PodeValidNetworkFailure -Exception $_.Exception) { return } - $_ | Write-PodeErrorLog throw } + } } @@ -432,7 +423,6 @@ function Write-PodeFileResponse { StatusCode = $StatusCode Cache = $Cache FileBrowser = $FileBrowser - NoEscape = $NoEscape } # path or file info? @@ -530,6 +520,9 @@ The status code to set against the response. .PARAMETER NoEscape If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. +.PARAMETER ETag +An optional ETag value to be set in the response headers. If not provided, it will be generated based on the content. + .EXAMPLE Write-PodeCsvResponse -Value "Name`nRick" @@ -561,7 +554,11 @@ function Write-PodeCsvResponse { [Parameter(ParameterSetName = 'File')] [switch] - $NoEscape + $NoEscape, + + [Parameter()] + [string] + $ETag ) begin { @@ -606,7 +603,7 @@ function Write-PodeCsvResponse { $Value = [string]::Empty } - Write-PodeTextResponse -Value $Value -ContentType 'text/csv' -StatusCode $StatusCode + Write-PodeTextResponse -Value $Value -ContentType 'text/csv' -StatusCode $StatusCode -ETag $ETag } } @@ -629,6 +626,9 @@ The status code to set against the response. .PARAMETER NoEscape If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. +.PARAMETER ETag +An optional ETag value to be set in the response headers. If not provided, it will be generated based on the content. + .EXAMPLE Write-PodeHtmlResponse -Value "Raw HTML can be placed here" @@ -660,7 +660,11 @@ function Write-PodeHtmlResponse { [Parameter(ParameterSetName = 'File')] [switch] - $NoEscape + $NoEscape, + + [Parameter()] + [string] + $ETag ) begin { @@ -696,7 +700,7 @@ function Write-PodeHtmlResponse { $Value = [string]::Empty } - Write-PodeTextResponse -Value $Value -ContentType 'text/html' -StatusCode $StatusCode + Write-PodeTextResponse -Value $Value -ContentType 'text/html' -StatusCode $StatusCode -ETag $ETag } } @@ -723,6 +727,9 @@ If supplied, the Markdown will be converted to HTML. (This is only supported in .PARAMETER NoEscape If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. +.PARAMETER ETag +An optional ETag value to be set in the response headers. If not provided, and cache is enabled, it will be generated based on the content. + .EXAMPLE Write-PodeMarkdownResponse -Value '# Hello, world!' -AsHtml @@ -754,7 +761,11 @@ function Write-PodeMarkdownResponse { [Parameter(ParameterSetName = 'File')] [switch] - $NoEscape + $NoEscape, + + [Parameter()] + [string] + $ETag ) begin { $pipelineItemCount = 0 @@ -789,7 +800,7 @@ function Write-PodeMarkdownResponse { } } - Write-PodeTextResponse -Value $Value -ContentType $mimeType -StatusCode $StatusCode + Write-PodeTextResponse -Value $Value -ContentType $mimeType -StatusCode $StatusCode -ETag $ETag } } @@ -822,6 +833,9 @@ The JSON document is not compressed (Human readable form) .PARAMETER NoEscape If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. +.PARAMETER ETag +An optional ETag value to be set in the response headers. If not provided, and cache is enabled, it will be generated based on the content. + .EXAMPLE Write-PodeJsonResponse -Value '{"name": "Rick"}' @@ -869,7 +883,11 @@ function Write-PodeJsonResponse { [Parameter(ParameterSetName = 'File')] [switch] - $NoEscape + $NoEscape, + + [Parameter()] + [string] + $ETag ) begin { @@ -907,7 +925,7 @@ function Write-PodeJsonResponse { $Value = '{}' } - Write-PodeTextResponse -Value $Value -ContentType $ContentType -StatusCode $StatusCode + Write-PodeTextResponse -Value $Value -ContentType $ContentType -StatusCode $StatusCode -ETag $ETag } } @@ -938,6 +956,9 @@ The status code to set against the response. .PARAMETER NoEscape If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. +.PARAMETER ETag +An optional ETag value to be set in the response headers. If not provided, and cache is enabled, it will be generated based on the content. + .EXAMPLE Write-PodeXmlResponse -Value 'Rick' @@ -1001,7 +1022,11 @@ function Write-PodeXmlResponse { [Parameter(ParameterSetName = 'File')] [switch] - $NoEscape + $NoEscape, + + [Parameter()] + [string] + $ETag ) begin { @@ -1038,7 +1063,7 @@ function Write-PodeXmlResponse { $Value = [string]::Empty } - Write-PodeTextResponse -Value $Value -ContentType $ContentType -StatusCode $StatusCode + Write-PodeTextResponse -Value $Value -ContentType $ContentType -StatusCode $StatusCode -ETag $ETag } } @@ -1068,6 +1093,9 @@ The status code to set against the response. .PARAMETER NoEscape If supplied, the path will not be escaped. This is useful for paths that contain expected wildcards, or are already escaped. +.PARAMETER ETag +An optional ETag value to be set in the response headers. If not provided, and cache is enabled, it will be generated based on the content. + .EXAMPLE Write-PodeYamlResponse -Value 'name: "Rick"' @@ -1112,7 +1140,11 @@ function Write-PodeYamlResponse { [Parameter(ParameterSetName = 'File')] [switch] - $NoEscape + $NoEscape, + + [Parameter()] + [string] + $ETag ) begin { @@ -1149,7 +1181,7 @@ function Write-PodeYamlResponse { $Value = '[]' } - Write-PodeTextResponse -Value $Value -ContentType $ContentType -StatusCode $StatusCode + Write-PodeTextResponse -Value $Value -ContentType $ContentType -StatusCode $StatusCode -ETag $ETag } } @@ -2054,4 +2086,4 @@ function Send-PodeResponse { if ($null -ne $WebEvent.Response) { $null = Wait-PodeTask -Task $WebEvent.Response.Send() } -} \ No newline at end of file +} diff --git a/src/Public/Routes.ps1 b/src/Public/Routes.ps1 index af08864c6..e2ccdb59d 100644 --- a/src/Public/Routes.ps1 +++ b/src/Public/Routes.ps1 @@ -140,7 +140,7 @@ function Add-PodeRoute { $ContentType, [Parameter()] - [ValidateSet('', 'gzip', 'deflate')] + [ValidateSet('', 'br', 'gzip', 'deflate')] [string] $TransferEncoding, @@ -448,6 +448,16 @@ function Add-PodeRoute { StatusCodes = @{} } } + Cache = @{ + Enabled = $false + MaxAge = 60 + } + Compression = @{ + Enabled = $PodeContext.Server.Web.Compression.Enabled + Encodings = $PodeContext.Server.Web.Compression.Encodings + Request = $false + Response = $PodeContext.Server.Web.Compression.Enabled + } } }) @@ -592,7 +602,7 @@ function Add-PodeStaticRoute { $ContentType, [Parameter()] - [ValidateSet('', 'gzip', 'deflate')] + [ValidateSet('', 'br', 'gzip', 'deflate')] [string] $TransferEncoding, @@ -888,6 +898,16 @@ function Add-PodeStaticRoute { StatusCodes = @{} } } + Cache = @{ + Enabled = $PodeContext.Server.Web.Static.Cache.Enabled + MaxAge = $PodeContext.Server.Web.Static.Cache.MaxAge + } + Compression = @{ + Enabled = $PodeContext.Server.Web.Compression.Enabled + Encodings = $PodeContext.Server.Web.Compression.Encodings + Request = $false + Response = $PodeContext.Server.Web.Compression.Enabled + } } }) @@ -1136,7 +1156,7 @@ function Add-PodeRouteGroup { $ContentType, [Parameter()] - [ValidateSet('', 'gzip', 'deflate')] + [ValidateSet('', 'br', 'gzip', 'deflate')] [string] $TransferEncoding, @@ -1384,7 +1404,7 @@ function Add-PodeStaticRouteGroup { $ContentType, [Parameter()] - [ValidateSet('', 'gzip', 'deflate')] + [ValidateSet('', 'br', 'gzip', 'deflate')] [string] $TransferEncoding, @@ -2806,4 +2826,281 @@ function Test-PodeSignalRoute { # check for routes return (Test-PodeRouteInternal -Method $Method -Path $Path -Protocol $endpoint.Protocol -Address $endpoint.Address) +} + + + +<# +.SYNOPSIS +Adds cache settings to Pode route definitions via the pipeline. + +.DESCRIPTION +The Add-PodeCache function allows you to apply HTTP caching behavior to Pode route hashtables. It supports enabling and disabling cache, as well as fine-grained control over directives such as Cache-Control visibility, max-age, ETag generation strategy, and immutability. It only applies caching to routes with GET, HEAD, or Static methods. + +.PARAMETER Route +An array of route hashtables to which cache settings will be applied. These must include a 'Method' key with values like 'Get', 'Head', or 'Static'. Other methods are ignored. + +.PARAMETER Enable +Enable caching for the provided route(s). Must be used with additional options in the 'Cache' parameter set. + +.PARAMETER Disable +Disables all cache behavior for the provided route(s), overriding any other settings. Cannot be combined with other cache parameters. + +.PARAMETER Visibility +Sets the visibility of the Cache-Control directive. Accepts: 'public', 'private', 'no-cache', 'no-store'. + +.PARAMETER MaxAge +Specifies the `max-age` directive in seconds for Cache-Control. + +.PARAMETER SharedMaxAge +Specifies the `s-maxage` directive in seconds for shared (proxy) caches. + +.PARAMETER MustRevalidate +Adds the `must-revalidate` directive to Cache-Control. + +.PARAMETER Immutable +Adds the `immutable` directive to Cache-Control to indicate that the resource will not change. + +.PARAMETER ETagMode +Controls how the ETag should be generated. Valid values: +- 'none': disables ETag +- 'auto': chooses 'mtime' for static, 'hash' for dynamic routes +- 'hash': generates ETag based on content hash +- 'mtime': generates ETag based on file modification time +- 'manual': allows manual ETag setting via the ETag parameter of Write-PodeTextResponse, Write-PodeYamlResponse, Write-PodeJsonResponse, Write-PodeCsvResponse, + Write-PodeHtmlResponse, and Write-PodexmlResponse cmdlets. + +.PARAMETER WeakValidation +If specified, the ETag is returned in weak form (prefixed with W/). + +.PARAMETER PassThru +If specified, the function outputs the modified route(s) back to the pipeline. + +.EXAMPLE +$routes | Add-PodeCache -Enable -MaxAge 3600 -ETagMode auto -Visibility public -PassThru + +Enables caching with a max age of 1 hour, automatic ETag strategy, and public visibility for all GET/HEAD routes in the $routes array. + +.EXAMPLE +$routes | Add-PodeCache -Disable + +Disables cache headers and ETag generation on all GET/HEAD routes. + +.NOTES +This function is intended to be used during route configuration in Pode, and will be ignored for unsupported methods. +#> + +function Add-PodeRouteCache { + [CmdletBinding(DefaultParameterSetName = 'Cache')] + [OutputType([hashtable[]])] + param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] + [ValidateNotNullOrEmpty()] + [hashtable[]] + $Route, + + [Parameter(ParameterSetName = 'Cache', Mandatory = $true)] + [switch] + $Enable, + + [Parameter(ParameterSetName = 'Cache')] + [ValidateSet('public', 'private', 'no-cache', 'no-store')] + [string] + $Visibility, + + [Parameter(ParameterSetName = 'Cache')] + [int] + $MaxAge, + + [Parameter(ParameterSetName = 'Cache')] + [int] + $SharedMaxAge, + + [Parameter(ParameterSetName = 'Cache')] + [switch] + $MustRevalidate, + + [Parameter(ParameterSetName = 'Cache')] + [switch] + $Immutable, + + [Parameter(ParameterSetName = 'Disabled')] + [switch] + $Disable, + + [Parameter(ParameterSetName = 'Cache')] + [ValidateSet('None', 'Auto', 'Hash', 'Mtime', 'Manual')] + [string] + $ETagMode = 'None', + + [Parameter(ParameterSetName = 'Cache')] + [switch] + $WeakValidation, + + [Parameter()] + [switch] + $PassThru + ) + + process { + foreach ($r in $Route) { + # Skip if Method is not GET or HEAD + if ( 'Get', 'Static', 'Head' -notcontains $r.Method) { + if ($PassThru) { $r } + continue + } + + if ($Disable) { + $r.cache.Enabled = $false + } + elseif ($Enable) { + $r.Cache.Enabled = $true + if ($Visibility) { $r.Cache.Visibility = $Visibility } + if ($MaxAge -gt 0) { $r.Cache.MaxAge = $MaxAge } + if ($SharedMaxAge -gt 0) { $r.Cache.SharedMaxAge = $SharedMaxAge } + if ($MustRevalidate) { $r.Cache.MustRevalidate = $true } + if ($Immutable) { $r.Cache.Immutable = $true } + if ($ETagMode -ne 'None') { + $r.Cache.ETag = @{ + Mode = $ETagMode + Weak = $WeakValidation.isPresent + } + } + } + + + if ($PassThru) { + $r + } + } + } +} + +<# +.SYNOPSIS +Enables or disables response compression for one or more Pode route hashtables. + +.DESCRIPTION +The Add-PodeRouteCompression function allows you to configure compression behavior on Pode routes. +It modifies route hashtables passed through the pipeline by setting their `.Compression` property. + +You can enable or disable compression explicitly. When enabled, you may optionally specify the allowed encoding methods +(e.g., gzip, deflate, br). This affects whether Pode compresses the response payload based on the client's Accept-Encoding +and the route’s configuration. + +This function is intended for use during server setup when constructing or importing route configurations. + +.PARAMETER Route +One or more route hashtables to modify. Must not be null or empty. Accepts pipeline input. + +.PARAMETER Enable +Enables compression on the specified routes. Required when enabling compression. + +.PARAMETER Disable +Disables compression on the specified routes. Required when disabling compression. + +.PARAMETER Encoding +Specifies one or more compression algorithms to allow. Valid values are: 'gzip', 'deflate', and 'br'. +This parameter is only valid when compression is being enabled. + +.PARAMETER PassThru +Returns the updated route hashtables to the pipeline. + +.PARAMETER Direction +Specifies the direction of compression. Valid values are: 'Request', 'Response', and 'Both'. +The default is 'Response'. This determines whether the route will compress incoming requests, outgoing responses, or both. + + +.OUTPUTS +System.Collections.Hashtable[] +If -PassThru is specified, the modified route objects are returned. + +.EXAMPLE +Add-PodeStaticRoute -Method Get -Path '/compress' -Source './' -PassThru | Add-PodeRouteCompression -Enable -Encoding gzip,br + +Enables gzip and Brotli compression on the given route(s). + +.EXAMPLE +$route | Add-PodeRouteCompression -Disable + +Disables response compression on the given route(s). + +.EXAMPLE +$routes = Get-Routes | Add-PodeRouteCompression -Enable -Encoding gzip + +Enables gzip compression and captures the updated route hashtables. + +.NOTES +This function is part of the Pode web framework. +Compression decisions at runtime depend on request headers and server capabilities. +#> +function Add-PodeRouteCompression { + [CmdletBinding(DefaultParameterSetName = 'Enable')] + [OutputType([hashtable[]])] + param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] + [ValidateNotNullOrEmpty()] + [hashtable[]] + $Route, + + [Parameter(ParameterSetName = 'Enable', Mandatory = $true)] + [switch] + $Enable, + + [Parameter(ParameterSetName = 'Disabled')] + [switch] + $Disable, + + [Parameter(ParameterSetName = 'Enable')] + [ValidateSet('gzip', 'deflate', 'br')] + [string[]] + $Encoding, + + [Parameter(ParameterSetName = 'Enable')] + [ValidateSet('Request', 'Response', 'Both')] + [string] + $Direction = 'Response', + + [Parameter()] + [switch] + $PassThru + ) + + process { + foreach ($r in $Route) { + if ($Disable) { + $r.Compression.Enabled = $false + $r.Compression.Request = $false + $r.Compression.Response = $false + } + elseif ($Enable) { + $r.Compression.Enabled = $true + if ($Encoding) { $r.Compression.Encodings = @($Encoding) } + + switch ($Direction) { + 'Request' { + $r.Compression.Request = $true + $r.Compression.Response = $false + } + 'Response' { + $r.Compression.Request = $false + $r.Compression.Response = $true + } + 'Both' { + $r.Compression.Request = $true + $r.Compression.Response = $true + } + default { + # This should never happen, but just in case + $r.Compression.Request = $false + $r.Compression.Response = $false + } + } + } + + if ($PassThru) { + $r + } + } + } } \ No newline at end of file diff --git a/tests/integration/RestApi.Tests.ps1 b/tests/integration/RestApi.Tests.ps1 index e2fd92cde..8ecb33d10 100644 --- a/tests/integration/RestApi.Tests.ps1 +++ b/tests/integration/RestApi.Tests.ps1 @@ -73,6 +73,10 @@ Describe 'REST API Requests' { Write-PodeJsonResponse -Value @{ Username = $WebEvent.Data.username } } + Add-PodeRoute -Method Post -Path '/contentencoding' -PassThru -ScriptBlock { + Write-PodeJsonResponse -Value @{ Username = $WebEvent.Data.username } + } | Add-PodeRouteCompression -Enable -Direction Request -Encoding gzip + Add-PodeRoute -Method * -Path '/all' -ScriptBlock { Write-PodeJsonResponse -Value @{ Result = 'OK' } } @@ -212,6 +216,24 @@ Describe 'REST API Requests' { $result.Username | Should -Be 'rick' } + It 'Encoded payload to gzip with Content-Encoding header' { + $data = @{ username = 'rick' } + $message = ($data | ConvertTo-Json) + + # compress the message using gzip + $bytes = [System.Text.Encoding]::UTF8.GetBytes($message) + $ms = [System.IO.MemoryStream]::new() + $gzip = [System.IO.Compression.GZipStream]::new($ms, [IO.Compression.CompressionMode]::Compress, $true) + $gzip.Write($bytes, 0, $bytes.Length) + $gzip.Close() + $ms.Position = 0 + + # make the request + $result = Invoke-RestMethod -Uri "$($Endpoint)/contentencoding" -Method Post -Body $ms.ToArray() ` + -ContentType 'application/json' -Headers @{ 'Content-Encoding' = 'gzip' } + $result.Username | Should -Be 'rick' + } + It 'works with any method' { $result = Invoke-RestMethod -Uri "$($Endpoint)/all" -Method Get $result.Result | Should -Be 'OK' diff --git a/tests/integration/WebDownload.Tests.ps1 b/tests/integration/WebDownload.Tests.ps1 new file mode 100644 index 000000000..1464f2532 --- /dev/null +++ b/tests/integration/WebDownload.Tests.ps1 @@ -0,0 +1,236 @@ +# Download.Tests.ps1 – Pester 5.x +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() + + +Describe 'Download endpoints' { + + + # ---------- 2. Environment set-up ---------- + BeforeAll { + $helperPath = (Split-Path -Parent -Path $PSCommandPath) -ireplace 'integration', 'shared' + . "$helperPath/TestHelper.ps1" + + $Port = 8080 + $Endpoint = "http://127.0.0.1:$Port" + + + Wait-ForWebServer -Port $Port -Offline + + $TestFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) 'pode-test' + $DownloadFolder = Join-Path -Path ([System.IO.Path]::GetTempPath()) 'pode-test-downloads' + # fresh test area + if (Test-Path $TestFolder) { Remove-Item $TestFolder -Recurse -Force } + New-Item $TestFolder -ItemType Directory | Out-Null + + if (Test-Path $DownloadFolder) { Remove-Item $DownloadFolder -Recurse -Force } + New-Item $DownloadFolder -ItemType Directory | Out-Null + # start Pode in a job + Start-Job -Name Pode -ScriptBlock { + Import-Module "$($using:PSScriptRoot)\..\..\src\Pode.psm1" + + Start-PodeServer -RootPath $using:PSScriptRoot -Quiet -ScriptBlock { + Add-PodeEndpoint -Address localhost -Port $using:Port -Protocol Http -DualMode + Add-PodeRoute -Method Get -Path '/close' -ScriptBlock { Close-PodeServer } + + Add-PodeStaticRoute -Path '/standard' -Source $using:TestFolder -FileBrowser + + Add-PodeStaticRoute -Path '/compress' -Source $using:TestFolder -FileBrowser -PassThru | + Add-PodeRouteCompression -Enable -Encoding gzip + + Add-PodeStaticRoute -Path '/cache' -Source $using:TestFolder -FileBrowser -PassThru | + Add-PodeRouteCache -Enable -MaxAge 10 -Visibility public -ETagMode mtime -MustRevalidate -PassThru | + Add-PodeRouteCompression -Enable -Encoding gzip + + } + } + + Wait-ForWebServer -Port $Port # server is now up + } + + AfterAll { + Receive-Job -Name 'Pode' | Out-Default + Invoke-RestMethod -Uri "$($Endpoint)/close" -Method Get | Out-Null + Get-Job -Name 'Pode' | Remove-Job -Force + if ((Test-Path $TestFolder)) { + Remove-Item $TestFolder -Recurse -Force + } + if ((Test-Path $DownloadFolder)) { + Remove-Item $DownloadFolder -Recurse -Force + } + } + + # ---------- 3. DATA-DRIVEN TESTS ---------- + Context 'Pode download standard, ranged, compressed' { + BeforeDiscovery { + $Sizes = @( + @{ Label = '1MB'; Bytes = 1MB; Tag = 'Quick' }, + @{ Label = '1GB'; Bytes = 1GB; Tag = 'Medium' }, + @{ Label = '3GB'; Bytes = 3GB; Tag = 'Large' }#, + #@{ Label = '8GB'; Bytes = 8GB; Tag = 'Huge' }, + #@{ Label = '13GB'; Bytes = 13GB; Tag = 'Enormous' } + ) + $Kinds = @('Text', 'Binary') + + $TestCases = foreach ($size in $Sizes) { + foreach ($kind in $Kinds) { + @{ + Kind = $kind + Label = $size.Label + Bytes = $size.Bytes + Tag = $size.Tag + Ext = $(if ($kind -eq 'Text') { '.txt' } else { '.bin' }) + ContentType = $(if ($kind -eq 'Text') { 'text/plain; charset=utf-8' } else { 'application/octet-stream' }) + } + } + } + + # expose to later blocks + # Set-Variable -Name TestCases -Value $TestCases -Scope Script + } + + + It 'Creates test files ' -ForEach $TestCases { + $dest = Join-Path -Path $TestFolder -ChildPath "$Tag$Ext" + + New-TestFile -Path $dest -SizeBytes $Bytes -Kind $Kind + (Test-Path -Path $dest -PathType Leaf) | Should -Be $true + } + # + # a) full download + # + It 'Full download matches for