Implemented Email API, with API Key system
Some checks failed
Build, Tag, Push Docker Image / build (push) Has been cancelled
Create Release / Generate Release (push) Has been cancelled

This commit is contained in:
Mia Rose Winter 2024-03-07 16:07:52 +01:00
parent 204ea4987e
commit 28cbec98ba
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
21 changed files with 1444 additions and 5 deletions

View file

@ -24,7 +24,7 @@
break; break;
} }
</div> </div>
<div> <div class="w-full">
@ChildContent @ChildContent
</div> </div>
@if (CanRemove) { @if (CanRemove) {

View file

@ -38,6 +38,7 @@
</AuthorizeView> </AuthorizeView>
<AuthorizeView Roles="Admin"> <AuthorizeView Roles="Admin">
<Authorized> <Authorized>
<li><NavLink href="manage/api">@Localizer["ManageApi_Label"]</NavLink></li>
<li><NavLink href="Newsletter">@Localizer["Newsletter_Label"]</NavLink></li> <li><NavLink href="Newsletter">@Localizer["Newsletter_Label"]</NavLink></li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>

View file

@ -72,6 +72,7 @@
} }
subscriber.Unsubscribed = true; subscriber.Unsubscribed = true;
subscriber.UnsubscribeReason = "User used unsubscribe Link";
await context.SaveChangesAsync(); await context.SaveChangesAsync();
Messages.ShowSuccess(Localizer["Unsubscribe_Success"]); Messages.ShowSuccess(Localizer["Unsubscribe_Success"]);

View file

@ -108,9 +108,11 @@
subscriber ??= new EmailSubscriber { subscriber ??= new EmailSubscriber {
Email = Model.Email.Trim(), Email = Model.Email.Trim(),
Unsubscribed = true, Language = "en-US" // TODO Unsubscribed = true,
Language = "en-US" // TODO
}; };
subscriber.Name = Model.Name; subscriber.Name = Model.Name;
subscriber.UnsubscribeReason = "Not Confirmed";
context.Update(subscriber); context.Update(subscriber);
await context.SaveChangesAsync(); await context.SaveChangesAsync();

View 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; }
}
}

View file

@ -3,6 +3,8 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.OutputCaching;
using Wave.Data; using Wave.Data;
using Wave.Data.Api; using Wave.Data.Api;
@ -29,6 +31,19 @@ public class ApiController(ApplicationDbContext context, IOptions<Customization>
return TypedResults.Ok(ArticleDto.GetFromArticle(article, GetHost(), profilePictureSize)); 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() { private Uri GetHost() {
string customUrl = customizationOptions.Value.AppUrl; string customUrl = customizationOptions.Value.AppUrl;

View 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
View 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();
}

View file

@ -114,5 +114,14 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
subscriber.HasQueryFilter(s => !s.Unsubscribed); subscriber.HasQueryFilter(s => !s.Unsubscribed);
subscriber.ToTable("NewsletterSubscribers"); 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);
});
} }
} }

View 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
}
}
}

View 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");
}
}

View file

@ -155,6 +155,50 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("AspNetUserTokens", (string)null); 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 => modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -560,6 +604,14 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.IsRequired(); .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 => modelBuilder.Entity("Wave.Data.Article", b =>
{ {
b.HasOne("Wave.Data.ApplicationUser", "Author") b.HasOne("Wave.Data.ApplicationUser", "Author")
@ -632,6 +684,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity("Wave.Data.ApiKey", b =>
{
b.Navigation("ApiClaims");
});
modelBuilder.Entity("Wave.Data.ApplicationUser", b => modelBuilder.Entity("Wave.Data.ApplicationUser", b =>
{ {
b.Navigation("Articles"); b.Navigation("Articles");

View file

@ -12,6 +12,7 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using StackExchange.Redis; using StackExchange.Redis;
using System.Text; using System.Text;
using AspNetCore.Authentication.ApiKey;
using Tomlyn.Extensions.Configuration; using Tomlyn.Extensions.Configuration;
using Wave.Components; using Wave.Components;
using Wave.Components.Account; using Wave.Components.Account;
@ -90,11 +91,18 @@
.AddPolicy("CategoryManagePermissions", p => p.RequireRole("Admin")) .AddPolicy("CategoryManagePermissions", p => p.RequireRole("Admin"))
.AddPolicy("RoleAssignPermissions", 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 => { builder.Services.AddAuthentication(options => {
options.DefaultScheme = IdentityConstants.ApplicationScheme; options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme; options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
}).AddIdentityCookies(); }).AddApiKeyInHeader<ApiKeyProvider>(ApiKeyDefaults.AuthenticationScheme, options => {
options.KeyName = "X-API-KEY";
options.Realm = "Wave API";
})
.AddIdentityCookies();
#endregion #endregion

View file

@ -128,4 +128,7 @@
<data name="ManageCategories_Label" xml:space="preserve"> <data name="ManageCategories_Label" xml:space="preserve">
<value>Kategorien Verwalten</value> <value>Kategorien Verwalten</value>
</data> </data>
<data name="ManageApi_Label" xml:space="preserve">
<value>API Verwalten</value>
</data>
</root> </root>

View file

@ -131,4 +131,7 @@
<data name="Newsletter_Label" xml:space="preserve"> <data name="Newsletter_Label" xml:space="preserve">
<value>Newsletter</value> <value>Newsletter</value>
</data> </data>
<data name="ManageApi_Label" xml:space="preserve">
<value>Manage API</value>
</data>
</root> </root>

View 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>

View 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>

View 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>

View 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;
}
}

View file

@ -10,6 +10,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.Authentication.ApiKey" Version="8.0.1" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" /> <PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Humanizer.Core.de" Version="2.14.1" /> <PackageReference Include="Humanizer.Core.de" Version="2.14.1" />
<PackageReference Include="Humanizer.Core.uk" Version="2.14.1" /> <PackageReference Include="Humanizer.Core.uk" Version="2.14.1" />

File diff suppressed because one or more lines are too long