InteractiveComputer/Core/RidgeBrowserPolicy.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace PaneOS.InteractiveComputer.Core;

public static class RidgeBrowserPolicy
{
	private static readonly IReadOnlyList<string> DefaultCreditHosts = new[]
	{
		"github.com",
		"flaticon.com",
		"www.flaticon.com"
	};

	public static RidgePolicyResult Evaluate( string rawUrl, string? webRenderingEnabled, string? allowedHosts )
	{
		var normalizedUrl = NormalizeUrl( rawUrl );
		var hosts = MergeDefaultHosts( ParseHostList( allowedHosts ?? "" ) );
		var renderingEnabled = IsTruthy( webRenderingEnabled );

		if ( normalizedUrl.StartsWith( "paneos://", StringComparison.OrdinalIgnoreCase ) )
		{
			return new RidgePolicyResult
			{
				NormalizedUrl = normalizedUrl,
				Title = "Ridge",
				Body = "Website rendering is disabled by default. Enable web_rendering_enabled and add hosts to allowed_hosts in this app's installed settings to permit specific sites.",
				Status = "Ready",
				MessageClass = "home",
				AllowedHosts = hosts
			};
		}

		if ( !Uri.TryCreate( normalizedUrl, UriKind.Absolute, out var uri ) || string.IsNullOrWhiteSpace( uri.Host ) )
		{
			return new RidgePolicyResult
			{
				NormalizedUrl = normalizedUrl,
				Title = "This address is not valid",
				Body = "Enter a full http or https URL, or use paneos://home.",
				Status = "Invalid address",
				MessageClass = "blocked",
				AllowedHosts = hosts
			};
		}

		if ( uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps )
		{
			return new RidgePolicyResult
			{
				NormalizedUrl = normalizedUrl,
				Title = "Protocol blocked",
				Body = "Ridge only supports http and https URLs when website rendering is enabled.",
				Status = "Blocked",
				MessageClass = "blocked",
				AllowedHosts = hosts
			};
		}

		var isDefaultCreditHost = IsHostAllowed( uri.Host, DefaultCreditHosts );
		if ( !renderingEnabled && !isDefaultCreditHost )
		{
			return new RidgePolicyResult
			{
				NormalizedUrl = normalizedUrl,
				Title = "Website rendering disabled",
				Body = "This PaneOS computer is configured to avoid rendering external websites.",
				Status = "Rendering disabled",
				MessageClass = "blocked",
				AllowedHosts = hosts
			};
		}

		if ( !IsHostAllowed( uri.Host, hosts ) )
		{
			return new RidgePolicyResult
			{
				NormalizedUrl = normalizedUrl,
				Title = "Site not allowed",
				Body = $"{uri.Host} is not in this computer's Ridge allow list.",
				Status = "Blocked by allow list",
				MessageClass = "blocked",
				AllowedHosts = hosts
			};
		}

		return new RidgePolicyResult
		{
			CanRenderWebPanel = true,
			NormalizedUrl = normalizedUrl,
			Title = uri.Host,
			Status = $"Loaded {uri.Host}",
			MessageClass = "allowed",
			AllowedHosts = hosts
		};
	}

	public static string NormalizeUrl( string rawUrl )
	{
		var value = string.IsNullOrWhiteSpace( rawUrl ) ? "paneos://home" : rawUrl.Trim();
		if ( value.StartsWith( "paneos://", StringComparison.OrdinalIgnoreCase ) )
			return value;

		if ( value.Contains( "://" ) )
			return value;

		return $"https://{value}";
	}

	public static bool IsTruthy( string? value )
	{
		return value is not null && (
			value.Equals( "true", StringComparison.OrdinalIgnoreCase )
			|| value.Equals( "1", StringComparison.OrdinalIgnoreCase )
			|| value.Equals( "yes", StringComparison.OrdinalIgnoreCase )
			|| value.Equals( "on", StringComparison.OrdinalIgnoreCase ) );
	}

	public static IReadOnlyList<string> ParseHostList( string value )
	{
		return value
			.Split( new[] { ',', ';', '\r', '\n', '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries )
			.Select( x => x.ToLowerInvariant() )
			.Distinct()
			.ToArray();
	}

	public static bool IsHostAllowed( string host, IReadOnlyList<string> allowedHosts )
	{
		var normalizedHost = host.ToLowerInvariant();
		foreach ( var allowedHost in allowedHosts )
		{
			if ( normalizedHost == allowedHost )
				return true;

			if ( allowedHost.StartsWith( "*.", StringComparison.Ordinal ) && normalizedHost.EndsWith( allowedHost[1..], StringComparison.Ordinal ) )
				return true;
		}

		return false;
	}

	private static IReadOnlyList<string> MergeDefaultHosts( IReadOnlyList<string> configuredHosts )
	{
		return DefaultCreditHosts
			.Concat( configuredHosts )
			.Select( x => x.ToLowerInvariant() )
			.Distinct()
			.ToArray();
	}
}

public sealed class RidgePolicyResult
{
	public bool CanRenderWebPanel { get; init; }
	public string NormalizedUrl { get; init; } = "";
	public string Title { get; init; } = "";
	public string Body { get; init; } = "";
	public string Status { get; init; } = "";
	public string MessageClass { get; init; } = "";
	public IReadOnlyList<string> AllowedHosts { get; init; } = Array.Empty<string>();
}