Implemented Email API, with API Key system
This commit is contained in:
parent
204ea4987e
commit
28cbec98ba
|
@ -24,7 +24,7 @@
|
|||
break;
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
@ChildContent
|
||||
</div>
|
||||
@if (CanRemove) {
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
</AuthorizeView>
|
||||
<AuthorizeView Roles="Admin">
|
||||
<Authorized>
|
||||
<li><NavLink href="manage/api">@Localizer["ManageApi_Label"]</NavLink></li>
|
||||
<li><NavLink href="Newsletter">@Localizer["Newsletter_Label"]</NavLink></li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
|
|
@ -72,6 +72,7 @@
|
|||
}
|
||||
|
||||
subscriber.Unsubscribed = true;
|
||||
subscriber.UnsubscribeReason = "User used unsubscribe Link";
|
||||
await context.SaveChangesAsync();
|
||||
Messages.ShowSuccess(Localizer["Unsubscribe_Success"]);
|
||||
|
||||
|
|
|
@ -108,9 +108,11 @@
|
|||
|
||||
subscriber ??= new EmailSubscriber {
|
||||
Email = Model.Email.Trim(),
|
||||
Unsubscribed = true, Language = "en-US" // TODO
|
||||
Unsubscribed = true,
|
||||
Language = "en-US" // TODO
|
||||
};
|
||||
subscriber.Name = Model.Name;
|
||||
subscriber.UnsubscribeReason = "Not Confirmed";
|
||||
context.Update(subscriber);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
|
|
148
Wave/Components/Pages/ManageApi.razor
Normal file
148
Wave/Components/Pages/ManageApi.razor
Normal file
|
@ -0,0 +1,148 @@
|
|||
@page "/manage/api"
|
||||
@using Wave.Data
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Security.Cryptography
|
||||
@using Wave.Utilities
|
||||
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
||||
@inject ILogger<ManageApi> Logger
|
||||
@inject IMessageDisplay Message
|
||||
@inject IStringLocalizer<ManageApi> Localizer
|
||||
|
||||
<PageTitle>@(TitlePrefix + Localizer["Title"])</PageTitle>
|
||||
|
||||
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["Title"]</h1>
|
||||
|
||||
<section>
|
||||
@if (!string.IsNullOrWhiteSpace(Key)) {
|
||||
<Alert CanRemove="true" Type="Alert.MessageType.Information">
|
||||
<p>@Localizer["NewApiKey_Message"]</p>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-square" onclick="navigator.clipboard.writeText('@Key')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M15.988 3.012A2.25 2.25 0 0 1 18 5.25v6.5A2.25 2.25 0 0 1 15.75 14H13.5V7A2.5 2.5 0 0 0 11 4.5H8.128a2.252 2.252 0 0 1 1.884-1.488A2.25 2.25 0 0 1 12.25 1h1.5a2.25 2.25 0 0 1 2.238 2.012ZM11.5 3.25a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 .75.75v.25h-3v-.25Z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd" d="M2 7a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7Zm2 3.25a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Zm0 3.5a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="bg-base-100 text-base-content w-0 grow overflow-x-auto">
|
||||
<code class="p-2 whitespace-nowrap">@Key</code>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@Localizer["Owner_Header"]</th>
|
||||
<th>@Localizer["Claims_Header"]</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var apiKey in ApiKeys) {
|
||||
<tr>
|
||||
<td>@apiKey.OwnerName</td>
|
||||
<td>@string.Join(", ", apiKey.Claims.Select(c => c.Type))</td>
|
||||
<td>
|
||||
<form method="post" @formname="@apiKey.Key" @onsubmit="async () => await DeleteApiKey(apiKey)">
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="btn btn-square btn-error" title="@Localizer["Delete_Label"]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<EditForm method="post" FormName="NewApiKey" Model="@Model" OnValidSubmit="CreateApiKey">
|
||||
<div class="join join-vertical md:join-horizontal w-full">
|
||||
<span class="btn no-animation join-item">@Localizer["Name_Label"]</span>
|
||||
<InputText @bind-Value="@Model.Name" placeholder="@Localizer["Name_Placeholder"]"
|
||||
required aria-required max="128" class="input input-bordered join-item"
|
||||
autocomplete="off" />
|
||||
<button type="submit" class="btn btn-primary join-item">@Localizer["Submit"]</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[CascadingParameter(Name = "TitlePrefix")]
|
||||
private string TitlePrefix { get; set; } = default!;
|
||||
[SupplyParameterFromForm(FormName = "NewApiKey")]
|
||||
private InputModel Model { get; set; } = new();
|
||||
|
||||
private List<ApiKey> ApiKeys { get; set; } = [];
|
||||
|
||||
private string? Key { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
|
||||
ApiKeys = await context.Set<ApiKey>().Include(a => a.ApiClaims).ToListAsync();
|
||||
}
|
||||
|
||||
private async Task CreateApiKey() {
|
||||
try {
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
|
||||
using var generator = RandomNumberGenerator.Create();
|
||||
byte[] data = new byte[128];
|
||||
generator.GetBytes(data);
|
||||
string key = Convert.ToBase64String(data);
|
||||
|
||||
var apiKey = new ApiKey {
|
||||
OwnerName = Model.Name!,
|
||||
Key = Convert.ToBase64String(SHA256.HashData(data)),
|
||||
ApiClaims = {
|
||||
new ApiClaim(0, "EmailApi", "EmailApi")
|
||||
}
|
||||
};
|
||||
await context.AddAsync(apiKey);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Message.ShowSuccess(Localizer["Create_Success"]);
|
||||
Model = new();
|
||||
Key = key;
|
||||
ApiKeys.Add(apiKey);
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, "Failed to create API key");
|
||||
Message.ShowError(Localizer["Create_Error"]);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteApiKey(ApiKey key) {
|
||||
try {
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
|
||||
context.Remove(key);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Message.ShowSuccess(Localizer["Delete_Success"]);
|
||||
Key = null;
|
||||
ApiKeys.Remove(key);
|
||||
} catch (Exception ex) {
|
||||
Logger.LogError(ex, "Failed to create API key");
|
||||
Message.ShowError(Localizer["Delete_Error"]);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel {
|
||||
[Required(AllowEmptyStrings = false), MinLength(3), MaxLength(128)]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
}
|
|
@ -3,6 +3,8 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Wave.Data;
|
||||
using Wave.Data.Api;
|
||||
|
||||
|
@ -29,6 +31,19 @@ public class ApiController(ApplicationDbContext context, IOptions<Customization>
|
|||
return TypedResults.Ok(ArticleDto.GetFromArticle(article, GetHost(), profilePictureSize));
|
||||
}
|
||||
|
||||
[HttpGet("email/subscriber/{email}")]
|
||||
[Produces("application/json")]
|
||||
[Authorize("EmailApi")]
|
||||
[OutputCache(Duration = 60*10)]
|
||||
public async Task<Results<Ok<EmailSubscriberDto>, NotFound>> GetEmailSubscriber([EmailAddress] string email) {
|
||||
var subscriber = await context.Set<EmailSubscriber>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(s => s.Email == email);
|
||||
if (subscriber is null) return TypedResults.NotFound();
|
||||
|
||||
return TypedResults.Ok(new EmailSubscriberDto(subscriber));
|
||||
}
|
||||
|
||||
private Uri GetHost() {
|
||||
string customUrl = customizationOptions.Value.AppUrl;
|
||||
|
||||
|
|
12
Wave/Data/Api/EmailSubscriberDto.cs
Normal file
12
Wave/Data/Api/EmailSubscriberDto.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
namespace Wave.Data.Api;
|
||||
|
||||
public record EmailSubscriberDto(
|
||||
string Email, string? Name, string Language,
|
||||
bool Unsubscribed, string? Reason,
|
||||
DateTimeOffset? LastReceived, DateTimeOffset? LastOpened) {
|
||||
|
||||
public EmailSubscriberDto(EmailSubscriber subscriber)
|
||||
: this(subscriber.Email, subscriber.Name, subscriber.Language,
|
||||
subscriber.Unsubscribed, subscriber.UnsubscribeReason,
|
||||
subscriber.LastMailReceived, subscriber.LastMailOpened) {}
|
||||
}
|
21
Wave/Data/ApiKey.cs
Normal file
21
Wave/Data/ApiKey.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using AspNetCore.Authentication.ApiKey;
|
||||
|
||||
namespace Wave.Data;
|
||||
|
||||
public record ApiClaim(
|
||||
[property:Key] int Id,
|
||||
[property:MaxLength(128)] string Type,
|
||||
[property:MaxLength(128)] string Value);
|
||||
|
||||
public class ApiKey : IApiKey {
|
||||
[Key, MaxLength(128)]
|
||||
public required string Key { get; init; }
|
||||
[MaxLength(128)]
|
||||
public required string OwnerName { get; set; }
|
||||
|
||||
public List<ApiClaim> ApiClaims { get; } = [];
|
||||
|
||||
public IReadOnlyCollection<Claim> Claims => ApiClaims.Select(api => new Claim(api.Type, api.Value)).ToList();
|
||||
}
|
|
@ -114,5 +114,14 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
|
|||
subscriber.HasQueryFilter(s => !s.Unsubscribed);
|
||||
subscriber.ToTable("NewsletterSubscribers");
|
||||
});
|
||||
|
||||
builder.Entity<ApiKey>(key => {
|
||||
key.HasKey(k => k.Key);
|
||||
key.Property(k => k.Key).IsRequired().HasMaxLength(128);
|
||||
key.Property(k => k.OwnerName).IsRequired().HasMaxLength(128);
|
||||
|
||||
key.HasMany(k => k.ApiClaims).WithOne().OnDelete(DeleteBehavior.Cascade);
|
||||
key.Ignore(k => k.Claims);
|
||||
});
|
||||
}
|
||||
}
|
711
Wave/Data/Migrations/postgres/20240307130813_ApiKeys.Designer.cs
generated
Normal file
711
Wave/Data/Migrations/postgres/20240307130813_ApiKeys.Designer.cs
generated
Normal file
|
@ -0,0 +1,711 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Wave.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Wave.Data.Migrations.postgres
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20240307130813_ApiKeys")]
|
||||
partial class ApiKeys
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("Npgsql:CollationDefinition:default-case-insensitive", "und-u-kf-upper-ks-level1,und-u-kf-upper-ks-level1,icu,False")
|
||||
.HasAnnotation("ProductVersion", "8.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApiClaim", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ApiKeyKey")
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApiKeyKey");
|
||||
|
||||
b.ToTable("ApiClaim");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApiKey", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("OwnerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("ApiKey");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AboutTheAuthor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Biography")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
|
||||
b.Property<string>("BiographyHtml")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("ContactPhone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("ContactPhoneBusiness")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("ContactWebsite")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.Article", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AuthorId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BodyHtml")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BodyPlain")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTimeOffset>("CreationDate")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("LastModified")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTimeOffset>("PublishDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReviewerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorId");
|
||||
|
||||
b.HasIndex("ReviewerId");
|
||||
|
||||
b.ToTable("Articles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ArticleCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Guid>("ArticleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CategoryId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ArticleId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("ArticleCategories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ArticleImage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ArticleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ImageDescription")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ArticleId");
|
||||
|
||||
b.ToTable("Images", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.Category", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Color")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(25);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.UseCollation("default-case-insensitive");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.EmailNewsletter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Guid>("ArticleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("DistributionDateTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsSend")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ArticleId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Newsletter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.EmailSubscriber", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.UseCollation("default-case-insensitive");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasDefaultValue("en-US");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMailOpened")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMailReceived")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("UnsubscribeReason")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("Unsubscribed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Unsubscribed");
|
||||
|
||||
b.ToTable("NewsletterSubscribers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ApplicationUserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("ImageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApplicationUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ProfilePictures", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.UserLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ApplicationUserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UrlString")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApplicationUserId");
|
||||
|
||||
b.ToTable("UserLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Wave.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApiClaim", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApiKey", null)
|
||||
.WithMany("ApiClaims")
|
||||
.HasForeignKey("ApiKeyKey")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.Article", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApplicationUser", "Author")
|
||||
.WithMany("Articles")
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Wave.Data.ApplicationUser", "Reviewer")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewerId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("Reviewer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ArticleCategory", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.Article", "Article")
|
||||
.WithMany()
|
||||
.HasForeignKey("ArticleId")
|
||||
.OnDelete(DeleteBehavior.NoAction)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Wave.Data.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.NoAction)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Article");
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ArticleImage", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.Article", null)
|
||||
.WithMany("Images")
|
||||
.HasForeignKey("ArticleId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.EmailNewsletter", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.Article", "Article")
|
||||
.WithOne()
|
||||
.HasForeignKey("Wave.Data.EmailNewsletter", "ArticleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Article");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ProfilePicture", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApplicationUser", null)
|
||||
.WithOne("ProfilePicture")
|
||||
.HasForeignKey("Wave.Data.ProfilePicture", "ApplicationUserId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.UserLink", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApplicationUser", null)
|
||||
.WithMany("Links")
|
||||
.HasForeignKey("ApplicationUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApiKey", b =>
|
||||
{
|
||||
b.Navigation("ApiClaims");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("Articles");
|
||||
|
||||
b.Navigation("Links");
|
||||
|
||||
b.Navigation("ProfilePicture");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.Article", b =>
|
||||
{
|
||||
b.Navigation("Images");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
54
Wave/Data/Migrations/postgres/20240307130813_ApiKeys.cs
Normal file
54
Wave/Data/Migrations/postgres/20240307130813_ApiKeys.cs
Normal file
|
@ -0,0 +1,54 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Wave.Data.Migrations.postgres;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class ApiKeys : Migration {
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder) {
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKey",
|
||||
columns: table => new {
|
||||
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
OwnerName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table => { table.PrimaryKey("PK_ApiKey", x => x.Key); });
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiClaim",
|
||||
columns: table => new {
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy",
|
||||
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Type = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Value = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
ApiKeyKey = table.Column<string>(type: "character varying(128)", nullable: true)
|
||||
},
|
||||
constraints: table => {
|
||||
table.PrimaryKey("PK_ApiClaim", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ApiClaim_ApiKey_ApiKeyKey",
|
||||
column: x => x.ApiKeyKey,
|
||||
principalTable: "ApiKey",
|
||||
principalColumn: "Key",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiClaim_ApiKeyKey",
|
||||
table: "ApiClaim",
|
||||
column: "ApiKeyKey");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder) {
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiClaim");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiKey");
|
||||
}
|
||||
}
|
|
@ -155,6 +155,50 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApiClaim", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ApiKeyKey")
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApiKeyKey");
|
||||
|
||||
b.ToTable("ApiClaim");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApiKey", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("OwnerName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("ApiKey");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
@ -560,6 +604,14 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApiClaim", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApiKey", null)
|
||||
.WithMany("ApiClaims")
|
||||
.HasForeignKey("ApiKeyKey")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.Article", b =>
|
||||
{
|
||||
b.HasOne("Wave.Data.ApplicationUser", "Author")
|
||||
|
@ -632,6 +684,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApiKey", b =>
|
||||
{
|
||||
b.Navigation("ApiClaims");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("Articles");
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using System.Text;
|
||||
using AspNetCore.Authentication.ApiKey;
|
||||
using Tomlyn.Extensions.Configuration;
|
||||
using Wave.Components;
|
||||
using Wave.Components.Account;
|
||||
|
@ -90,11 +91,18 @@
|
|||
.AddPolicy("CategoryManagePermissions", p => p.RequireRole("Admin"))
|
||||
.AddPolicy("RoleAssignPermissions", p => p.RequireRole("Admin"))
|
||||
|
||||
.AddPolicy("ArticleEditOrReviewPermissions", p => p.RequireRole("Author", "Reviewer", "Admin"));
|
||||
.AddPolicy("ArticleEditOrReviewPermissions", p => p.RequireRole("Author", "Reviewer", "Admin"))
|
||||
|
||||
.AddPolicy("EmailApi", p => p.RequireClaim("EmailApi")
|
||||
.AddAuthenticationSchemes(ApiKeyDefaults.AuthenticationScheme));
|
||||
builder.Services.AddAuthentication(options => {
|
||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
||||
}).AddIdentityCookies();
|
||||
}).AddApiKeyInHeader<ApiKeyProvider>(ApiKeyDefaults.AuthenticationScheme, options => {
|
||||
options.KeyName = "X-API-KEY";
|
||||
options.Realm = "Wave API";
|
||||
})
|
||||
.AddIdentityCookies();
|
||||
|
||||
#endregion
|
||||
|
||||
|
|
|
@ -128,4 +128,7 @@
|
|||
<data name="ManageCategories_Label" xml:space="preserve">
|
||||
<value>Kategorien Verwalten</value>
|
||||
</data>
|
||||
<data name="ManageApi_Label" xml:space="preserve">
|
||||
<value>API Verwalten</value>
|
||||
</data>
|
||||
</root>
|
|
@ -131,4 +131,7 @@
|
|||
<data name="Newsletter_Label" xml:space="preserve">
|
||||
<value>Newsletter</value>
|
||||
</data>
|
||||
<data name="ManageApi_Label" xml:space="preserve">
|
||||
<value>Manage API</value>
|
||||
</data>
|
||||
</root>
|
131
Wave/Resources/Components/Pages/ManageApi.de-DE.resx
Normal file
131
Wave/Resources/Components/Pages/ManageApi.de-DE.resx
Normal file
|
@ -0,0 +1,131 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 1.3
|
||||
|
||||
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">1.3</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1">this is my long string</data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
[base64 mime encoded serialized .NET Framework object]
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
[base64 mime encoded string representing a byte array form of the .NET Framework object]
|
||||
</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.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:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<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" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
</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>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>API Verwalten</value>
|
||||
</data>
|
||||
<data name="NewApiKey_Message" xml:space="preserve">
|
||||
<value>Ihr neuer API-Schlüssel:</value>
|
||||
</data>
|
||||
<data name="Claims_Header" xml:space="preserve">
|
||||
<value>Zugriffsberechtigungen</value>
|
||||
</data>
|
||||
<data name="Name_Label" xml:space="preserve">
|
||||
<value>Schlüsselbezeichnung</value>
|
||||
</data>
|
||||
<data name="Name_Placeholder" xml:space="preserve">
|
||||
<value>Meine Software</value>
|
||||
</data>
|
||||
<data name="Submit" xml:space="preserve">
|
||||
<value>Erstellen</value>
|
||||
</data>
|
||||
<data name="Create_Success" xml:space="preserve">
|
||||
<value>Schlüssel Erstellt</value>
|
||||
</data>
|
||||
<data name="Create_Error" xml:space="preserve">
|
||||
<value>Unerwarteter Fehler beim Versuch einen neuen API-Schlüssel zu erstellen</value>
|
||||
</data>
|
||||
<data name="Delete_Success" xml:space="preserve">
|
||||
<value>API-Schlüssel wurde gelöscht</value>
|
||||
</data>
|
||||
<data name="Delete_Error" xml:space="preserve">
|
||||
<value>Unerwarteter Fehler beim Versuch einen API-Schlüssel zu löschen</value>
|
||||
</data>
|
||||
</root>
|
101
Wave/Resources/Components/Pages/ManageApi.en-GB.resx
Normal file
101
Wave/Resources/Components/Pages/ManageApi.en-GB.resx
Normal file
|
@ -0,0 +1,101 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 1.3
|
||||
|
||||
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">1.3</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1">this is my long string</data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
[base64 mime encoded serialized .NET Framework object]
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
[base64 mime encoded string representing a byte array form of the .NET Framework object]
|
||||
</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.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:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<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" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
</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>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
134
Wave/Resources/Components/Pages/ManageApi.resx
Normal file
134
Wave/Resources/Components/Pages/ManageApi.resx
Normal file
|
@ -0,0 +1,134 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 1.3
|
||||
|
||||
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">1.3</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1">this is my long string</data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
[base64 mime encoded serialized .NET Framework object]
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
[base64 mime encoded string representing a byte array form of the .NET Framework object]
|
||||
</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.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:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<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" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
</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>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Manage API</value>
|
||||
</data>
|
||||
<data name="NewApiKey_Message" xml:space="preserve">
|
||||
<value>Your new API-Key:</value>
|
||||
</data>
|
||||
<data name="Owner_Header" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="Claims_Header" xml:space="preserve">
|
||||
<value>Access Rights</value>
|
||||
</data>
|
||||
<data name="Name_Label" xml:space="preserve">
|
||||
<value>Key Name</value>
|
||||
</data>
|
||||
<data name="Name_Placeholder" xml:space="preserve">
|
||||
<value>My Software</value>
|
||||
</data>
|
||||
<data name="Submit" xml:space="preserve">
|
||||
<value>Create</value>
|
||||
</data>
|
||||
<data name="Create_Success" xml:space="preserve">
|
||||
<value>Key Created</value>
|
||||
</data>
|
||||
<data name="Create_Error" xml:space="preserve">
|
||||
<value>Unexpected error while trying to create a new API Key</value>
|
||||
</data>
|
||||
<data name="Delete_Error" xml:space="preserve">
|
||||
<value>Unexpected error while trying to delete API Key</value>
|
||||
</data>
|
||||
<data name="Delete_Success" xml:space="preserve">
|
||||
<value>API Key has been deleted</value>
|
||||
</data>
|
||||
</root>
|
27
Wave/Services/ApiKeyProvider.cs
Normal file
27
Wave/Services/ApiKeyProvider.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using AspNetCore.Authentication.ApiKey;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Wave.Data;
|
||||
|
||||
namespace Wave.Services;
|
||||
|
||||
public class ApiKeyProvider(ILogger<ApiKeyProvider> logger, ApplicationDbContext context) : IApiKeyProvider {
|
||||
private ILogger<ApiKeyProvider> Logger { get; } = logger;
|
||||
|
||||
private record ActualApiKey(string Key, string OwnerName, IReadOnlyCollection<Claim> Claims) : IApiKey;
|
||||
|
||||
public async Task<IApiKey?> ProvideAsync(string key) {
|
||||
try {
|
||||
byte[] data = Convert.FromBase64String(key);
|
||||
string hashedKey = Convert.ToBase64String(SHA256.HashData(data));
|
||||
|
||||
var apiKey = await context.Set<ApiKey>().Include(a => a.ApiClaims).SingleOrDefaultAsync(k => k.Key == hashedKey);
|
||||
if (apiKey is not null)
|
||||
return new ActualApiKey(key, apiKey.OwnerName, apiKey.Claims);
|
||||
} catch (Exception ex) {
|
||||
Logger.LogWarning(ex, "Failed to get api key");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.Authentication.ApiKey" Version="8.0.1" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="Humanizer.Core.de" Version="2.14.1" />
|
||||
<PackageReference Include="Humanizer.Core.uk" Version="2.14.1" />
|
||||
|
|
2
Wave/wwwroot/css/main.min.css
vendored
2
Wave/wwwroot/css/main.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue