sgammon/GUST

View on GitHub
java/gust/backend/AppController.java

Summary

Maintainability
A
2 hrs
Test Coverage
/*
 * Copyright © 2020, The Gust Framework Authors. All rights reserved.
 *
 * The Gust/Elide framework and tools, and all associated source or object computer code, except where otherwise noted,
 * are licensed under the Zero Prosperity license, which is enclosed in this repository, in the file LICENSE.txt. Use of
 * this code in object or source form requires and implies consent and agreement to that license in principle and
 * practice. Source or object code not listing this header, or unless specified otherwise, remain the property of
 * Elide LLC and its suppliers, if any. The intellectual and technical concepts contained herein are proprietary to
 * Elide LLC and its suppliers and may be covered by U.S. and Foreign Patents, or patents in process, and are protected
 * by trade secret and copyright law. Dissemination of this information, or reproduction of this material, in any form,
 * is strictly forbidden except in adherence with assigned license requirements.
 */
package gust.backend;

import com.google.common.base.Joiner;
import com.google.common.html.types.TrustedResourceUrlProto;
import gust.backend.runtime.Logging;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.MutableHttpResponse;
import org.slf4j.Logger;
import tools.elide.page.Context.ClientHint;
import tools.elide.page.Context.FramingPolicy;
import tools.elide.page.Context.ReferrerPolicy;

import javax.annotation.Nonnull;
import javax.inject.Inject;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.function.BiConsumer;

import static java.lang.String.format;


/**
 * Describes a framework application controller, which is pre-loaded with convenient access to tooling, and any injected
 * application logic. Alternatively, if the invoking developer wishes to use their own base class, they can acquire most
 * or all of the enclosed functionality via injection.
 *
 * <p>Various properties are made available to sub-classes which allow easy access to stuff like:</p>
 * <ul>
 *   <li><b>{@code context}:</b> This property exposes a builder for the current flow's page context. This can be
 *       manipulated to provide/inject properties, and then subsequently used in Soy.</li>
 * </ul>
 *
 * <p>To make use of this controller, simply inherit from it in your own {@code @Controller}-annotated class. When a
 * request method is invoked, the logic provided by this object will have been initialized and will be ready to use.</p>
 */
@SuppressWarnings("unused")
public abstract class AppController extends BaseController {
  /** Private logging pipe for {@code AppController}. */
  private final Logger logging = Logging.logger(AppController.class);

  /** Pre-ordained HTML object which ensures the character encoding is set to UTF-8. */
  protected final static @Nonnull MediaType HTML;

  /** Configuration for dynamic serving. */
  private @Inject DynamicServingConfiguration servingConfiguration;

  /** Configuration for dynamic serving. */
  private @Inject AssetConfiguration assetConfiguration;

  static {
    // initialize HTML page type
    HTML = new MediaType(MediaType.TEXT_HTML_TYPE.getName(), MediaType.TEXT_HTML_TYPE.getExtension(),
      Collections.singletonMap("charset", StandardCharsets.UTF_8.displayName()));
  }

  /**
   * Initialize a new application controller.
   *
   * @param context Injected page context manager.
   */
  protected AppController(@Nonnull PageContextManager context) {
    super(context);
  }

  // -- API: Trusted Resources (Delegated to Context) -- //

  /**
   * Generate a trusted resource URL for the provided Java URL.
   *
   * @param url Pre-ordained trusted resource URL.
   * @return Trusted resource URL specification proto.
   */
  public @Nonnull TrustedResourceUrlProto trustedResource(@Nonnull URL url) {
    return context.trustedResource(url);
  }

  /**
   * Generate a trusted resource URL for the provided Java URI.
   *
   * @param uri Pre-ordained trusted resource URI.
   * @return Trusted resource URL specification proto.
   */
  public @Nonnull TrustedResourceUrlProto trustedResource(@Nonnull URI uri) {
    return context.trustedResource(uri);
  }

  // -- API: Configurable Serving -- //

  /**
   * Affix headers to the provided response, according to the provided app {@code config} for dynamic serving. The
   * resulting response is kept mutable for further changes.
   *
   * @param response HTTP response to affix headers to.
   * @return HTTP response, with headers affixed.
   */
  @SuppressWarnings({"WeakerAccess", "SameParameterValue"})
  protected @Nonnull <T> MutableHttpResponse<T> affixHeaders(@Nonnull MutableHttpResponse<T> response,
                                                             @Nonnull DynamicServingConfiguration config) {
    // first up: content language
    if (this.context.language().isPresent()) {
      var lang = this.context.language().get();
      logging.debug(format("Affixing `Content-Language` header from context: '%s'.", lang));
      response.setAttribute("language", lang);
    } else if (config.language().isPresent()) {
      logging.debug(format("Affixing `Content-Language` header from config: '%s'.", config.language().get()));
      response.setAttribute("language", config.language().get());
      this.context.language(config.language());
    } else if (logging.isTraceEnabled()) {
      logging.trace("No `Content-Language` header value to set.");
    }

    // next up: etags
    if (config.etags().enabled()) {
      if (logging.isTraceEnabled())
        logging.trace("Dynamic `ETag` values are enabled.");
      this.context.enableETags(true);
    } else if (logging.isTraceEnabled()) {
      logging.trace("Dynamic `ETag` values are disabled.");
    }

    // next up: client hints
    if (config.clientHints().enabled()) {
      Set<ClientHint> hints = config.clientHints().hints();
      if (!hints.isEmpty()) {
        if (logging.isDebugEnabled())
          logging.debug(format("Client hints are ENABLED. Applying hints: '%s'.", Joiner.on(", ").join(hints)));
        this.context.supportedClientHints(
          Optional.of(hints),
          config.clientHints().ttl().isPresent() ?
            Optional.of(config.clientHints().ttlUnit().toSeconds(config.clientHints().ttl().get())) :
            Optional.empty());

      } else if (logging.isTraceEnabled()) {
        logging.trace("No client hints are enabled.");
      }
    } else if (logging.isTraceEnabled()) {
      logging.trace("Client hints are DISABLED.");
    }

    // next up: vary
    if (config.variance().enabled()) {
      Set<String> varySet = new TreeSet<>();
      response.setAttribute("vary", varySet);
      DynamicServingConfiguration.DynamicVarianceConfiguration varyConfig = config.variance();

      BiConsumer<Boolean, String> affixVarySegment = (condition, header) -> {
        if (condition) {
          varySet.add(header);
          if (logging.isDebugEnabled()) {
            logging.debug(format("Indicating dynamic response variance by `%s` (due to config).", header));
          }
        } else if (logging.isTraceEnabled()) {
          logging.trace(format("Dynamic response variance by `%s` is DISABLED (due to config).", header));
        }
      };

      affixVarySegment.accept(varyConfig.accept(), HttpHeaders.ACCEPT);  // `Accept`
      affixVarySegment.accept(varyConfig.charset(), HttpHeaders.ACCEPT_CHARSET);  // `Accept-Charset`
      affixVarySegment.accept(varyConfig.encoding(), HttpHeaders.ACCEPT_ENCODING);  // `Accept-Encoding`
      affixVarySegment.accept(varyConfig.language(), HttpHeaders.ACCEPT_LANGUAGE);  // `Accept-Language`
      affixVarySegment.accept(varyConfig.origin(), HttpHeaders.ORIGIN);  // `Origin`
      if (!varySet.isEmpty()) {
        if (logging.isDebugEnabled())
          logging.debug(format("Indicating configured variance for response: '%s'.", Joiner.on(", ").join(varySet)));
        this.context.vary(varySet);
      } else if (logging.isTraceEnabled()) {
        logging.trace("No variance configured for response.");
      }
    }

    // next up: feature policy
    if (config.featurePolicy().enabled()) {
      SortedSet<String> featurePolicies = config.featurePolicy().policy();
      if (logging.isDebugEnabled())
        logging.debug(format("Indicating `Feature-Policy` for response: '%s'.", Joiner.on(", ").join(featurePolicies)));
      featurePolicies.forEach((policy) ->
        this.context.getContext().addFeaturePolicy(policy));
    } else if (logging.isDebugEnabled()) {
      logging.debug("`Feature-Policy` disabled via config.");
    }

    // next up: referrer policy
    if (config.referrerPolicy() != ReferrerPolicy.DEFAULT_REFERRER_POLICY) {
      if (logging.isDebugEnabled())
        logging.debug(format("Applying `Referrer-Policy`: '%s'.", config.referrerPolicy().name()));
      this.context.getContext().setReferrerPolicy(config.referrerPolicy());
    } else if (logging.isDebugEnabled()) {
      logging.debug("`Referrer-Policy` disabled via config.");
    }

    // next up: framing policy
    if (config.framingPolicy() != FramingPolicy.DEFAULT_FRAMING_POLICY) {
      if (logging.isDebugEnabled())
        logging.debug(format("Applying `X-Frame-Options` policy: '%s'.", config.framingPolicy().name()));
      this.context.getContext().setFramingPolicy(config.framingPolicy());
    } else if (logging.isDebugEnabled()) {
      logging.debug("`X-Frame-Options` disabled via config.");
    }

    // next up: DNS domain prefetch
    if (!config.dnsPrefetch().isEmpty()) {
      if (logging.isDebugEnabled())
        logging.debug(format("Pre-fetching %s domains via browser DNS: '%s'.",
          config.dnsPrefetch().size(),
          Joiner.on(", ").join(config.dnsPrefetch())));

      this.context.dnsPrefetch(config.dnsPrefetch());

    } else if (logging.isDebugEnabled()) {
      logging.debug("No domains to prefetch via DNS.");
    }

    // next up: domain pre-connect
    if (!config.preconnect().isEmpty()) {
      if (logging.isDebugEnabled())
        logging.debug(format("Pre-connecting to %s domains via browser: '%s'.",
          config.dnsPrefetch().size(),
          Joiner.on(", ").join(config.preconnect())));

      this.context.preconnect(config.preconnect());

    } else if (logging.isDebugEnabled()) {
      logging.debug("No domains to pre-connect to.");
    }

    // next up: `nosniff`
    if (config.noSniff()) {
      if (logging.isDebugEnabled())
        logging.debug("Indicating `nosniff` for `X-Content-Type-Options`.");
      this.context.getContext().setContentTypeNosniff(true);
    } else if (logging.isDebugEnabled())
      logging.debug("`X-Content-Type-Options` disabled via config.");

    // next up: `X-XSS-Protection`
    if (config.xssProtection().enabled()) {
      String xssProtectToken = config.xssProtection().filter() ? "1" : "0";
      String modeToken = config.xssProtection().block() ? "; mode=block" : "";
      String composedHeader = xssProtectToken + modeToken;

      if (logging.isDebugEnabled())
        logging.debug(format("Old-style `X-XSS-Protection` is enabled. Affixing header: '%s'.", composedHeader));
      this.context.getContext().setXssProtection(composedHeader);
    } else if (logging.isDebugEnabled())
      logging.debug("Old-style `X-XSS-Protection` is disabled by configuration.");

    // finally: arbitrary headers
    if (!config.additionalHeaders().isEmpty()) {
      config.additionalHeaders().forEach(this.context::header);
    }
    return response;
  }

  /**
   * Affix headers to the provided response, according to current app configuration for dynamic serving. The resulting
   * response is kept mutable for further changes.
   *
   * @param response HTTP response to affix headers to.
   * @return HTTP response, with headers affixed.
   */
  protected @Nonnull <T> MutableHttpResponse<T> affixHeaders(@Nonnull MutableHttpResponse<T> response) {
    return affixHeaders(response, DynamicServingConfiguration.DEFAULTS);
  }

  /**
   * Select the set of CDN prefixes to use in this HTTP cycle, from the configured set. Once this action completes, the
   * set of CDN prefixes is considered "frozen" for this cycle.
   *
   * @param render Page context manager, after the handler has completed.
   * @param response Response object, on which we should set the CDN prefix property.
   * @param servingConfig Serving configuration from which to calculate the active set of CDN prefixes.
   */
  @SuppressWarnings("WeakerAccess")
  protected void selectCdnPrefixes(@Nonnull PageContextManager render,
                                   @Nonnull MutableHttpResponse response,
                                   @Nonnull AssetConfiguration servingConfig) {
    if (!render.getCdnPrefix().isPresent()) {
      // resolve CDN prefix to use for this run
      if (servingConfig.cdn().enabled()) {
        List<String> hostnames = servingConfig.cdn().hostnames();
        if (hostnames.isEmpty() && logging.isDebugEnabled()) {
          logging.debug("No CDN prefixes available.");
        } else if (!hostnames.isEmpty()) {
          final String hostname;
          if (hostnames.size() == 1) {
            hostname = hostnames.get(0);
          } else {
            // select one
            hostname = hostnames.get(new Random().nextInt(hostnames.size()));
          }
          if (logging.isDebugEnabled())
            logging.debug(format("Selected CDN prefix for HTTP cycle: '%s'.", hostname));
          response.setAttribute("cdn_prefix", hostname);
          render.cdnPrefix(Optional.of(hostname));

          // add to pre-connect list and DNS prefetch list
          render.dnsPrefetch(hostname);
          render.preconnect(hostname);
        }

      } else if (logging.isDebugEnabled()) {
        logging.debug("CDN prefix skipped (DISABLED via config).");
      }
    } else if (logging.isDebugEnabled()) {
      logging.debug("Deferring to developer-specified CDN prefix.");
    }
  }

  /**
   * Serve the provided rendered-page response, while applying any app configuration related to dynamic page headers.
   * This may include headers like {@code Vary}, {@code ETag}, and so on, which may be calculated based on the response
   * intended to be provided to the client.
   *
   * @param render Page render to perform before responding.
   * @return Prepped and rendered HTTP response.
   */
  protected @Nonnull MutableHttpResponse<PageRender> serve(@Nonnull PageContextManager render) {
    DynamicServingConfiguration servingConfig = (
      this.servingConfiguration != null ? this.servingConfiguration : DynamicServingConfiguration.DEFAULTS);
    AssetConfiguration assetConfig = (
      this.assetConfiguration != null ? this.assetConfiguration : AssetConfiguration.DEFAULTS);

    // order matters here. `selectCdnPrefix` must be called first, to load any CDN prefix before link pre-loads go out.
    // next, `affixHeaders` must be called before `render`, which produces the `ctx` that is set on the response (so it
    // may be picked up in `PageContextManager#finalizeResponse`).
    MutableHttpResponse<PageRender> response = HttpResponse.ok(render);
    selectCdnPrefixes(render, response, assetConfig);
    this.affixHeaders(response, servingConfig);
    response.setAttribute("context", render.render().getPageContext());
    return response;
  }
}