Fix #4936: add the cookie consent theme control.

This commit is contained in:
Ben 2025-02-22 09:49:33 +08:00
parent 7a4ea8cf1b
commit 982f3b1943
14 changed files with 322 additions and 0 deletions

View File

@ -52,6 +52,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddScoped<IVisitorService, VisitorService>();
services.AddScoped<ISyncService, SyncService>();
services.AddScoped<ILocalizationCookieService, LocalizationCookieService>();
services.AddScoped<ICookieConsentService, CookieConsentService>();
// providers
services.AddScoped<ITextEditor, Oqtane.Modules.Controls.QuillJSTextEditor>();

View File

@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ConsentBody" xml:space="preserve">
<value>
&lt;div class="gdpr-consent-bar bg-light text-dark p-3 fixed-bottom"&gt;
&lt;div class="container-fluid d-flex justify-content-between align-items-center"&gt;
&lt;div&gt;
By clicking "Accept", you agree us to use cookies to ensure you get the best experience on our website.
&lt;/div&gt;
&lt;button class="btn btn-primary" type="submit"&gt;Accept&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
</value>
</data>
</root>

View File

@ -0,0 +1,31 @@
using Oqtane.Models;
using System.Threading.Tasks;
using System.Net.Http;
using System;
using Oqtane.Documentation;
using Oqtane.Shared;
using System.Globalization;
namespace Oqtane.Services
{
/// <inheritdoc cref="ICookieConsentService" />
[PrivateApi("Don't show in the documentation, as everything should use the Interface")]
public class CookieConsentService : ServiceBase, ICookieConsentService
{
public CookieConsentService(HttpClient http, SiteState siteState) : base(http, siteState) { }
private string ApiUrl => CreateApiUrl("CookieConsent");
/// <inheritdoc />
public async Task<bool> CanTrackAsync()
{
return await GetJsonAsync<bool>($"{ApiUrl}/CanTrack");
}
public async Task<string> CreateConsentCookieAsync()
{
var cookie = await GetStringAsync($"{ApiUrl}/CreateConsentCookie");
return cookie ?? string.Empty;
}
}
}

View File

@ -0,0 +1,24 @@
using Oqtane.Models;
using System;
using System.Threading.Tasks;
namespace Oqtane.Services
{
/// <summary>
/// Service to retrieve cookie consent information.
/// </summary>
public interface ICookieConsentService
{
/// <summary>
/// Get cookie consent status
/// </summary>
/// <returns></returns>
Task<bool> CanTrackAsync();
/// <summary>
/// Grant cookie consent
/// </summary>
/// <returns></returns>
Task<string> CreateConsentCookieAsync();
}
}

View File

@ -26,9 +26,11 @@
<div class="container">
<div class="row px-4">
<Pane Name="@PaneNames.Admin" />
<CookieConsent />
</div>
</div>
</div>
</div>
@code {

View File

@ -0,0 +1,33 @@
@namespace Oqtane.Themes.Controls
@inherits ThemeControlBase
@inject ICookieConsentService CookieConsentService
@inject IJSRuntime JSRuntime
@inject IStringLocalizer<CookieConsent> Localizer
@if (showBanner)
{
<form method="post" @formname="CookieConsentForm" @onsubmit="async () => await AcceptPolicy()" data-enhance>
<input type="hidden" name="@Constants.RequestVerificationToken" value="@SiteState.AntiForgeryToken" />
@((MarkupString)Convert.ToString(Localizer["ConsentBody"]))
</form>
}
@code {
private bool showBanner;
protected override async Task OnInitializedAsync()
{
showBanner = !(await CookieConsentService.CanTrackAsync());
}
private async Task AcceptPolicy()
{
var cookieString = await CookieConsentService.CreateConsentCookieAsync();
if (!string.IsNullOrEmpty(cookieString))
{
var interop = new Interop(JSRuntime);
await interop.SetCookieString(cookieString);
showBanner = false;
}
}
}

View File

@ -107,6 +107,7 @@
{
<Pane Name="Footer" />
}
<CookieConsent />
</div>
</main>

View File

@ -37,6 +37,19 @@ namespace Oqtane.UI
}
}
public Task SetCookieString(string cookieString)
{
try
{
_jsRuntime.InvokeVoidAsync("Oqtane.Interop.setCookieString", cookieString);
return Task.CompletedTask;
}
catch
{
return Task.CompletedTask;
}
}
public ValueTask<string> GetCookie(string name)
{
try

View File

@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Oqtane.Models;
using Oqtane.Shared;
using System;
using System.Globalization;
using Oqtane.Infrastructure;
using Oqtane.Services;
using System.Threading.Tasks;
namespace Oqtane.Controllers
{
[Route(ControllerRoutes.ApiRoute)]
public class CookieConsentController : Controller
{
private readonly ICookieConsentService _cookieConsentService;
public CookieConsentController(ICookieConsentService cookieConsentService)
{
_cookieConsentService = cookieConsentService;
}
[HttpGet("CanTrack")]
public async Task<bool> CanTrack()
{
return await _cookieConsentService.CanTrackAsync();
}
[HttpGet("CreateConsentCookie")]
public async Task<string> CreateConsentCookie()
{
return await _cookieConsentService.CreateConsentCookieAsync();
}
}
}

View File

@ -103,6 +103,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.AddScoped<ISearchService, SearchService>();
services.AddScoped<ISearchProvider, DatabaseSearchProvider>();
services.AddScoped<IImageService, ImageService>();
services.AddScoped<ICookieConsentService, ServerCookieConsentService>();
// providers
services.AddScoped<ITextEditor, Oqtane.Modules.Controls.QuillJSTextEditor>();

View File

@ -0,0 +1,38 @@
using System;
using System.Diagnostics.Contracts;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Localization;
using Oqtane.Documentation;
namespace Oqtane.Services
{
[PrivateApi("Don't show in the documentation, as everything should use the Interface")]
public class ServerCookieConsentService : ICookieConsentService
{
private readonly IHttpContextAccessor _accessor;
public ServerCookieConsentService(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
public Task<bool> CanTrackAsync()
{
var consentFeature = _accessor.HttpContext?.Features.Get<ITrackingConsentFeature>();
var canTrack = consentFeature?.CanTrack ?? true;
return Task.FromResult(canTrack);
}
public Task<string> CreateConsentCookieAsync()
{
var consentFeature = _accessor.HttpContext?.Features.Get<ITrackingConsentFeature>();
consentFeature?.GrantConsent();
var cookie = consentFeature?.CreateConsentCookie() ?? string.Empty;
return Task.FromResult(cookie);
}
}
}

View File

@ -169,6 +169,13 @@ namespace Oqtane
options.CustomSchemaIds(type => type.ToString()); // Handle SchemaId already used for different type
});
services.TryAddSwagger(_useSwagger);
//add cookie consent policy
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.ConsentCookieValue = Constants.CookieConsentCookieValue;
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@ -225,6 +232,7 @@ namespace Oqtane
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.UseCookiePolicy();
if (_useSwagger)
{

View File

@ -14,6 +14,9 @@ Oqtane.Interop = {
}
document.cookie = cookieString;
},
setCookieString: function (cookieString) {
document.cookie = cookieString;
},
getCookie: function (name) {
name = name + "=";
var decodedCookie = decodeURIComponent(document.cookie);

View File

@ -91,6 +91,7 @@ namespace Oqtane.Shared
public const string BootstrapStylesheetUrl = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css";
public const string BootstrapStylesheetIntegrity = "sha512-jnSuA4Ss2PkkikSOLtYs8BlYIeeIK1h99ty4YfvRPAlzr377vr3CXDb7sb7eEEBYjDtcYj+AjBH3FLv5uSJuXg==";
public const string CookieConsentCookieValue = "true";
// Obsolete constants
const string RoleObsoleteMessage = "Use the corresponding member from Oqtane.Shared.RoleNames";