|
12 | 12 | using Azure.DataApiBuilder.Core.Services; |
13 | 13 | using Azure.DataApiBuilder.Service.Exceptions; |
14 | 14 | using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes; |
15 | | -using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; |
| 15 | +using Microsoft.AspNetCore.Http; |
| 16 | +using Microsoft.AspNetCore.Http.Extensions; |
16 | 17 | using Microsoft.AspNetCore.WebUtilities; |
| 18 | +using QueryBuilder = Azure.DataApiBuilder.Service.GraphQLBuilder.Queries.QueryBuilder; |
17 | 19 |
|
18 | 20 | namespace Azure.DataApiBuilder.Core.Resolvers |
19 | 21 | { |
@@ -572,49 +574,101 @@ public static string Base64Decode(string base64EncodedData) |
572 | 574 | } |
573 | 575 |
|
574 | 576 | /// <summary> |
575 | | - /// Create the URL that will provide for the next page of results |
576 | | - /// using the same query options. |
577 | | - /// Return value formatted as a JSON array: [{"nextLink":"[base]/api/[entity]?[queryParams_URIescaped]$after=[base64encodedPaginationToken]"}] |
| 577 | + /// Constructs the base Uri for Pagination |
578 | 578 | /// </summary> |
579 | | - /// <param name="path">The request path excluding query parameters (e.g. https://localhost/api/myEntity)</param> |
580 | | - /// <param name="queryStringParameters">Collection of query string parameters that are URI escaped.</param> |
581 | | - /// <param name="newAfterPayload">The contents to add to the $after query parameter. Should be base64 encoded pagination token.</param> |
582 | | - /// <returns>JSON element - array with nextLink.</returns> |
583 | | - public static JsonElement CreateNextLink(string path, NameValueCollection? queryStringParameters, string newAfterPayload) |
| 579 | + /// <remarks> |
| 580 | + /// This method uses the "X-Forwarded-Proto" and "X-Forwarded-Host" headers to determine |
| 581 | + /// the scheme and host of the request, falling back to the request's original scheme and host if the headers |
| 582 | + /// are not present or invalid. The method ensures that the scheme is either "http" or "https" and that the host |
| 583 | + /// is a valid hostname or IP address. |
| 584 | + /// </remarks> |
| 585 | + /// <param name="httpContext">The HTTP context containing the request information.</param> |
| 586 | + /// <param name="baseRoute">An optional base route to prepend to the request path. If not specified, no base route is used.</param> |
| 587 | + /// <returns>A string representing the fully constructed Base request URL for Pagination.</returns> |
| 588 | + public static string ConstructBaseUriForPagination(HttpContext httpContext, string? baseRoute = null) |
| 589 | + { |
| 590 | + HttpRequest req = httpContext.Request; |
| 591 | + |
| 592 | + // use scheme from X-Forwarded-Proto or fallback to request scheme |
| 593 | + string scheme = ResolveRequestScheme(req); |
| 594 | + |
| 595 | + // Use host from X-Forwarded-Host or fallback to request host |
| 596 | + string host = ResolveRequestHost(req); |
| 597 | + |
| 598 | + // If the base route is not empty, we need to insert it into the URI before the rest path. |
| 599 | + // Path is of the form ....restPath/pathNameForEntity. We want to insert the base route before the restPath. |
| 600 | + // Finally, it will be of the form: .../baseRoute/restPath/pathNameForEntity. |
| 601 | + return UriHelper.BuildAbsolute( |
| 602 | + scheme: scheme, |
| 603 | + host: new HostString(host), |
| 604 | + pathBase: string.IsNullOrWhiteSpace(baseRoute) ? PathString.Empty : new PathString(baseRoute), |
| 605 | + path: req.Path); |
| 606 | + } |
| 607 | + |
| 608 | + /// <summary> |
| 609 | + /// Builds a query string by appending or replacing the <c>$after</c> token with the specified value. |
| 610 | + /// </summary> |
| 611 | + /// <remarks>This method does not include the <paramref name="path"/> in the returned query |
| 612 | + /// string. It only processes and formats the query string parameters.</remarks> |
| 613 | + /// <param name="queryStringParameters">A collection of existing query string parameters. If <see langword="null"/>, an empty collection is used. |
| 614 | + /// The <c>$after</c> parameter, if present, will be removed before appending the new token.</param> |
| 615 | + /// <param name="newAfterPayload">The new value for the <c>$after</c> token. If this value is <see langword="null"/>, empty, or whitespace, no |
| 616 | + /// <c>$after</c> token will be appended.</param> |
| 617 | + /// <returns>A URL-encoded query string containing the updated parameters, including the new <c>$after</c> token if |
| 618 | + /// specified. If no parameters are provided and <paramref name="newAfterPayload"/> is empty, an empty string is |
| 619 | + /// returned.</returns> |
| 620 | + public static string BuildQueryStringWithAfterToken(NameValueCollection? queryStringParameters, string newAfterPayload) |
584 | 621 | { |
585 | 622 | if (queryStringParameters is null) |
586 | 623 | { |
587 | 624 | queryStringParameters = new(); |
588 | 625 | } |
589 | 626 | else |
590 | 627 | { |
591 | | - // Purge old $after value so this function can replace it. |
592 | 628 | queryStringParameters.Remove("$after"); |
593 | 629 | } |
594 | 630 |
|
595 | | - // To prevent regression of current behavior, retain the call to FormatQueryString |
596 | | - // which URI escapes other query parameters. Since $after has been removed, |
597 | | - // this will not affect the base64 encoded paging token. |
598 | | - string queryString = FormatQueryString(queryStringParameters: queryStringParameters); |
| 631 | + // Format existing query string (URL encoded) |
| 632 | + string queryString = FormatQueryString(queryStringParameters); |
599 | 633 |
|
600 | | - // When a new $after payload is provided, append it to the query string with the |
601 | | - // appropriate prefix: ? if $after is the only query parameter. & if $after is one of many query parameters. |
| 634 | + // Append new $after token |
602 | 635 | if (!string.IsNullOrWhiteSpace(newAfterPayload)) |
603 | 636 | { |
604 | 637 | string afterPrefix = string.IsNullOrWhiteSpace(queryString) ? "?" : "&"; |
605 | 638 | queryString += $"{afterPrefix}{RequestParser.AFTER_URL}={newAfterPayload}"; |
606 | 639 | } |
607 | 640 |
|
608 | | - // ValueKind will be array so we can differentiate from other objects in the response |
609 | | - // to be returned. |
610 | | - // [{"nextLink":"[base]/api/[entity]?[queryParams_URIescaped]$after=[base64encodedPaginationToken]"}] |
| 641 | + // Construct final link |
| 642 | + // return $"{path}{queryString}"; |
| 643 | + return queryString; |
| 644 | + } |
| 645 | + |
| 646 | + /// <summary> |
| 647 | + /// Gets a consolidated next link for pagination in JSON format. |
| 648 | + /// </summary> |
| 649 | + /// <param name="baseUri">The base Pagination Uri</param> |
| 650 | + /// <param name="queryString">The query string with after value</param> |
| 651 | + /// <param name="isNextLinkRelative">True, if the next link should be relative</param> |
| 652 | + /// <returns></returns> |
| 653 | + public static JsonElement GetConsolidatedNextLinkForPagination(string baseUri, string queryString, bool isNextLinkRelative = false) |
| 654 | + { |
| 655 | + UriBuilder uriBuilder = new(baseUri) |
| 656 | + { |
| 657 | + // Form final link by appending the query string |
| 658 | + Query = queryString |
| 659 | + }; |
| 660 | + |
| 661 | + // Construct final link- absolute or relative |
| 662 | + string nextLinkValue = isNextLinkRelative |
| 663 | + ? uriBuilder.Uri.PathAndQuery // returns just "/api/<Entity>?$after...", no host |
| 664 | + : uriBuilder.Uri.AbsoluteUri; // returns full URL |
| 665 | + |
| 666 | + // Return serialized JSON object |
611 | 667 | string jsonString = JsonSerializer.Serialize(new[] |
612 | 668 | { |
613 | | - new |
614 | | - { |
615 | | - nextLink = @$"{path}{queryString}" |
616 | | - } |
| 669 | + new { nextLink = nextLinkValue } |
617 | 670 | }); |
| 671 | + |
618 | 672 | return JsonSerializer.Deserialize<JsonElement>(jsonString); |
619 | 673 | } |
620 | 674 |
|
@@ -695,5 +749,94 @@ public static string FormatQueryString(NameValueCollection? queryStringParameter |
695 | 749 |
|
696 | 750 | return queryString; |
697 | 751 | } |
| 752 | + |
| 753 | + /// <summary> |
| 754 | + /// Extracts and request scheme from "X-Forwarded-Proto" or falls back to the request scheme. |
| 755 | + /// </summary> |
| 756 | + /// <param name="req">The HTTP request.</param> |
| 757 | + /// <returns>The scheme string ("http" or "https").</returns> |
| 758 | + /// <exception cref="DataApiBuilderException">Thrown when client explicitly sets an invalid scheme.</exception> |
| 759 | + private static string ResolveRequestScheme(HttpRequest req) |
| 760 | + { |
| 761 | + string? rawScheme = req.Headers["X-Forwarded-Proto"].FirstOrDefault(); |
| 762 | + string? normalized = rawScheme?.Trim().ToLowerInvariant(); |
| 763 | + |
| 764 | + bool isExplicit = !string.IsNullOrEmpty(rawScheme); |
| 765 | + bool isValid = IsValidScheme(normalized); |
| 766 | + |
| 767 | + if (isExplicit && !isValid) |
| 768 | + { |
| 769 | + // Log a warning and ignore the invalid value, fallback to request's scheme |
| 770 | + Console.WriteLine($"Warning: Invalid scheme '{rawScheme}' in X-Forwarded-Proto header. Falling back to request scheme: '{req.Scheme}'."); |
| 771 | + return req.Scheme; |
| 772 | + } |
| 773 | + |
| 774 | + return isValid ? normalized! : req.Scheme; |
| 775 | + } |
| 776 | + |
| 777 | + /// <summary> |
| 778 | + /// Extracts the request host from "X-Forwarded-Host" or falls back to the request host. |
| 779 | + /// </summary> |
| 780 | + /// <param name="req">The HTTP request.</param> |
| 781 | + /// <returns>The host string.</returns> |
| 782 | + /// <exception cref="DataApiBuilderException">Thrown when client explicitly sets an invalid host.</exception> |
| 783 | + private static string ResolveRequestHost(HttpRequest req) |
| 784 | + { |
| 785 | + string? rawHost = req.Headers["X-Forwarded-Host"].FirstOrDefault(); |
| 786 | + string? trimmed = rawHost?.Trim(); |
| 787 | + |
| 788 | + bool isExplicit = !string.IsNullOrEmpty(rawHost); |
| 789 | + bool isValid = IsValidHost(trimmed); |
| 790 | + |
| 791 | + if (isExplicit && !isValid) |
| 792 | + { |
| 793 | + // Log a warning and ignore the invalid value, fallback to request's host |
| 794 | + Console.WriteLine($"Warning: Invalid host '{rawHost}' in X-Forwarded-Host header. Falling back to request host: '{req.Host}'."); |
| 795 | + return req.Host.ToString(); |
| 796 | + } |
| 797 | + |
| 798 | + return isValid ? trimmed! : req.Host.ToString(); |
| 799 | + } |
| 800 | + |
| 801 | + /// <summary> |
| 802 | + /// Checks if the provided scheme is valid. |
| 803 | + /// </summary> |
| 804 | + /// <param name="scheme">Scheme, e.g., "http" or "https".</param> |
| 805 | + /// <returns>True if valid, otherwise false.</returns> |
| 806 | + private static bool IsValidScheme(string? scheme) |
| 807 | + { |
| 808 | + return scheme is "http" or "https"; |
| 809 | + } |
| 810 | + |
| 811 | + /// <summary> |
| 812 | + /// Checks if the provided host is a valid hostname or IP address. |
| 813 | + /// </summary> |
| 814 | + /// <param name="host">The host name (with optional port).</param> |
| 815 | + /// <returns>True if valid, otherwise false.</returns> |
| 816 | + private static bool IsValidHost(string? host) |
| 817 | + { |
| 818 | + if (string.IsNullOrWhiteSpace(host)) |
| 819 | + { |
| 820 | + return false; |
| 821 | + } |
| 822 | + |
| 823 | + // Reject dangerous characters |
| 824 | + if (host.Contains('\r') || host.Contains('\n') || host.Contains(' ') || |
| 825 | + host.Contains('<') || host.Contains('>') || host.Contains('@')) |
| 826 | + { |
| 827 | + return false; |
| 828 | + } |
| 829 | + |
| 830 | + // Validate host part (exclude port if present) |
| 831 | + string hostnamePart = host.Split(':')[0]; |
| 832 | + |
| 833 | + if (Uri.CheckHostName(hostnamePart) == UriHostNameType.Unknown) |
| 834 | + { |
| 835 | + return false; |
| 836 | + } |
| 837 | + |
| 838 | + // Final sanity check: ensure it parses into a full URI |
| 839 | + return Uri.TryCreate($"http://{host}", UriKind.Absolute, out _); |
| 840 | + } |
698 | 841 | } |
699 | 842 | } |
0 commit comments