No description
  • C# 85.2%
  • HTML 10.2%
  • JavaScript 4.3%
  • Liquid 0.2%
  • CSS 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
cheapnud 8ae56f6966
All checks were successful
Publish NuGet / Publish Base Packages (push) Successful in 41s
Build / Build and Test (push) Successful in 27s
Publish NuGet / Build and Test (push) Successful in 30s
Publish NuGet / Publish MAUI (push) Successful in 54s
Publish NuGet / Publish MediaProcessing (push) Successful in 10m23s
Merge pull request 'Ship the Blazor static assets with the props manifest again' (#90) from b/89-static-asset-manifest into master
2026-10-22 19:02:38 +00:00
.forgejo Document CheapHelpers.Auth and wire it into the solutions and publish workflow 2026-10-20 02:38:22 +02:00
.github Drop the GitHub PR review workflow 2026-08-20 23:06:15 +02:00
CheapHelpers Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Auth Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Auth.Tests Bound the default key folder name and set the test folder mode explicitly 2026-10-20 03:01:59 +02:00
CheapHelpers.Avalonia.Bridge Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Barcodes Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Blazor Ship the Blazor static assets with the props manifest again 2026-10-22 20:59:51 +02:00
CheapHelpers.EF Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Llm Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Llm.Tests Keep a throwing usage subscriber from failing the LLM call 2026-10-22 16:50:57 +02:00
CheapHelpers.MAUI Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.MediaProcessing Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Models Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Networking Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Services Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
CheapHelpers.Tests Apply the dictionary key policy to string and enum keys in the JSON converters 2026-10-20 02:20:57 +02:00
Docs Bump the packages to 4.0.0 2026-10-22 17:29:53 +02:00
.gitattributes Add .gitattributes, .gitignore, README.md, and LICENSE.txt. 2025-05-17 16:25:24 +02:00
.gitignore updated gitignore 2026-03-13 12:03:04 +01:00
CheapHelpers.CI.slnx Document CheapHelpers.Auth and wire it into the solutions and publish workflow 2026-10-20 02:38:22 +02:00
CheapHelpers.slnx Document CheapHelpers.Auth and wire it into the solutions and publish workflow 2026-10-20 02:38:22 +02:00
Directory.Build.props Update Source Link to 10.0.402 to clear the Microsoft.Build.Tasks.Git advisory 2026-10-19 13:15:31 +02:00
LICENSE.txt setup nuget 2025-10-29 11:03:05 +01:00
NuGet.Config Use the shared NuGet cache instead of a repo-local packages folder 2026-10-17 15:01:57 +02:00
README.md Note the 4.0.1 static asset fix in the README 2026-10-22 20:59:55 +02:00
TODO.md Track work as forge issues; TODO points at the board 2026-09-23 22:55:41 +02:00

CheapHelpers

A collection of production-ready C# utilities, extensions, and services for .NET 11.0 development. Simplify common development tasks with battle-tested helpers for Blazor, Entity Framework, networking, email, PDF generation, billing, reporting, and more.

Packages

Package Version Description
CheapHelpers NuGet Core utilities, extensions, threading, and scheduling
CheapHelpers.Models NuGet Shared entities, DTOs, enums, and UBL invoice models
CheapHelpers.EF NuGet Entity Framework repository pattern with Identity support
CheapHelpers.Services NuGet Email, PDF, billing, reporting, notifications, and integrations
CheapHelpers.Barcodes NuGet Barcode generation and reading on ZXing.Net with no native image dependency
CheapHelpers.Auth NuGet Plex and OAuth sign-in, API keys, shared-secret auth, forwarded headers and Data Protection setup
CheapHelpers.Llm NuGet LLM client for Anthropic and OpenAI-compatible providers with structured output
CheapHelpers.Blazor NuGet Blazor components, account module, and Hybrid features
CheapHelpers.Networking NuGet Network scanning, mDNS discovery, and device detection
CheapHelpers.MAUI NuGet MAUI platform implementations (iOS APNS, Android FCM)
CheapHelpers.MediaProcessing NuGet FFmpeg integration, hardware detection (Windows/Linux)
CheapHelpers.Avalonia.Bridge NuGet Desktop notification bridge for Avalonia apps

Installation

# Core utilities and extensions
dotnet add package CheapHelpers

# Entity Framework repository pattern
dotnet add package CheapHelpers.EF

# Business services (email, PDF, billing, reporting, notifications)
dotnet add package CheapHelpers.Services

# Plex, OAuth and OpenID Connect sign-in, API keys, forwarded headers
dotnet add package CheapHelpers.Auth

# Blazor components, account module, and Hybrid features
dotnet add package CheapHelpers.Blazor

# Network scanning and device discovery
dotnet add package CheapHelpers.Networking

Quick Start

String Extensions

using CheapHelpers.Extensions;

"hello world".Capitalize();                    // "Hello world"
"0474123456".ToInternationalPhoneNumber();     // "+32474123456"
"Very long text here".TrimWithEllipsis(10);    // "Very long ..."
"my:invalid*file".SanitizeFileName();          // "my_invalid_file"

Email Service (3 providers)

using CheapHelpers.Services.Email;

// MailKit (SMTP), Microsoft Graph, or SendGrid
var emailService = new SendGridEmailService(
    fromName: "MyApp",
    fromAddress: "noreply@myapp.com",
    apiKey: "SG.xxx",
    inDev: false,
    developers: ["dev@myapp.com"]);

await emailService.SendEmailAsync(
    "user@example.com", "Welcome!", "<h1>Welcome!</h1>",
    attachments: [("invoice.pdf", pdfBytes)]);

Email Templates (Fluid/Liquid)

// Template data class
public class WelcomeEmailTemplateData : BaseEmailTemplateData
{
    public override string Subject => $"Welcome, {UserName}!";
    public string UserName { get; set; }
    public string ActivationLink { get; set; }
}

// Render and send
var templateService = new EmailTemplateService(...);
var result = await templateService.RenderEmailAsync(new WelcomeEmailTemplateData
{
    UserName = "John",
    ActivationLink = "https://myapp.com/activate/abc123"
});
await emailService.SendEmailAsync("john@example.com", result.Subject, result.HtmlBody);

Notification System

// Register with multi-channel support
services.AddCheapNotifications<MyUser>(options =>
{
    options.EnabledChannels = NotificationChannelFlags.InApp | NotificationChannelFlags.Email;
    options.RealTimeProvider = "SignalR"; // Default. Use "RabbitMQ" for multi-server
});

// Blazor real-time (SignalR)
services.AddCheapNotificationsBlazor();
app.MapCheapNotificationsHub();

// Optional: RabbitMQ for cross-server delivery (SignalR stays client-facing)
services.AddCheapNotificationsRabbitMQConsumer("amqp://localhost");

API Key System

In CheapHelpers.Auth; full setup in Docs/Auth.

services.AddCheapApiKeys<MyUser>(options =>
{
    options.KeyPrefix = "myapp_";
    options.DefaultRateLimitPerMinute = 100;
});

// Generate — full key only returned once
var result = await apiKeyService.GenerateAsync(
    userId, "Production API",
    scopes: ["read", "write"],
    prefixOverride: "VTQ_",        // Per-call prefix override
    expiresAt: DateTime.UtcNow.AddYears(1));
// result.FullKey = "VTQ_a7f3bc91..." (store immediately!)

// Validate
var validation = await apiKeyService.ValidateAsync(rawKey);
if (validation.IsValid)
    Debug.WriteLine($"User: {validation.UserId}, Scopes: {string.Join(",", validation.Scopes)}");

Billing Service (Metered Usage)

services.AddCheapBilling<MyUser>(options =>
{
    options.DefaultTaxRate = 21.00m; // Belgian VAT
    options.InvoiceNumberPrefix = "INV";
});

// Record API usage (fire-and-forget safe)
await usageMeter.RecordUsageAsync(apiKeyId, "/api/data", "GET", 200, durationMs: 42);

// Generate invoice for a billing period
var invoice = await billingService.GenerateInvoiceAsync(
    apiKeyId, billingPlanId,
    new DateTime(2026, 3, 1), new DateTime(2026, 3, 31));

PEPPOL BIS 3.0 Invoicing

using CheapHelpers.Services.DataExchange.Ubl;

// Simple API — fluent builder, no UBL knowledge needed
var invoice = InvoiceBuilder.Create("INV-2026-000001")
    .From("My Company", vatNumber: "BE0123456789")
    .To("Acme Corp", vatNumber: "BE9876543210")
    .DueIn(days: 30)
    .AddLine("API calls - March 2026", quantity: 50000, unitPrice: 0.001m, vatPercent: 21)
    .AddLine("Premium support", quantity: 1, unitPrice: 150.00m, vatPercent: 21)
    .WithPayment(iban: "BE68539007547034", bic: "BBRUBEBB")
    .Build();
// Tax totals, line totals, and monetary totals are calculated automatically

var invoiceService = new UblInvoiceService();
var xml = await invoiceService.CreateInvoiceXmlAsync(invoice);

// Credit notes work the same way
var creditNote = CreditNoteBuilder.Create("CN-2026-000001")
    .CreditsInvoice("INV-2026-000001")
    .WithReason("Overcharged API calls")
    .From("My Company", vatNumber: "BE0123456789")
    .To("Acme Corp", vatNumber: "BE9876543210")
    .AddLine("API call correction", quantity: 10000, unitPrice: 0.001m, vatPercent: 21)
    .Build();

// Advanced: use UblInvoice DTOs directly for full PEPPOL control
var advancedInvoice = new UblInvoice { /* full UBL model access */ };

Reporting Service

services.AddCheapReporting<MyUser>(options =>
{
    options.StorageProvider = StorageProviderType.AzureBlob;
    options.AzureBlobConnectionString = "...";
    options.DefaultRetentionDays = 90;
});

// Generate PDF report
var result = await reportService.GenerateReportAsync(new ReportRequest<SalesRecord>
{
    Name = "Monthly Sales",
    Format = ReportFormat.Pdf,
    Data = salesRecords,
    Template = myPdfTemplate,
    DistributeToEmails = ["manager@company.com"], // Auto-email after generation
    RetentionDays = 30
});

// Download later
var (content, fileName, mimeType) = await reportService.DownloadReportAsync(result.ReportId);

Authentication (Multiple Providers)

In CheapHelpers.Auth; full setup, including shared-secret auth, forwarded headers and Data Protection, in Docs/Auth.

// Plex SSO
services.AddPlexAuth(options => { options.AdminToken = "..."; });
app.MapPlexAuthEndpoints(); // /auth/plex-start, /auth/plex-callback, /auth/logout

// Google + Microsoft OAuth
services.AddGoogleAuth(options => { options.ClientId = "..."; options.ClientSecret = "..."; });
services.AddMicrosoftAuth(options => { options.ClientId = "..."; options.ClientSecret = "..."; });

// GitHub + Apple OAuth
services.AddGitHubAuth(options => { options.ClientId = "..."; });
services.AddAppleAuth(options => { options.TeamId = "..."; options.KeyId = "..."; });

// Optional: auto-provision CheapUser from external auth
services.AddExternalUserProvisioning<MyUser>();

Blazor Account Module

// Register services
services.AddCheapHelpersBlazor<MyUser>(options =>
{
    options.UseEmailService<SendGridEmailService>();
    options.AccountRouteOptions = new AccountRouteOptions
    {
        LoginRoute = "/auth/login",
    };
});

// Map the account and culture endpoints (after app.UseAntiforgery()):
app.MapCheapAccountEndpoints<MyUser>();
app.MapCheapCultureEndpoint();

// Built-in pages: Login, Register, ForgotPassword, ResetPassword,
// ConfirmEmail, ConfirmEmailChange, SetPassword, ChangePassword,
// Authenticator (2FA), RecoveryCodes, PersonalData (GDPR), Lockout
// Account dashboard with extensible custom tabs

Migrating from the account controller

Delete your CheapAccountController<TUser, TContext> subclass and its [Route] attribute. Call app.MapCheapAccountEndpoints<TUser>() and app.MapCheapCultureEndpoint() after app.UseAntiforgery(). Sign-out is now POST only, so replace any sign-out link with a <form method="post"> that contains <AntiforgeryToken />. A forced redirect after sign-in, such as a password change, goes in the AfterSignIn option instead of an overridden action. OAuthProviderOptions.LoginPath and PlexAuthOptions.LoginPath now default to /Account/Login, so an app that relied on the old /login default must set them explicitly. The barcode types moved to the CheapHelpers.Barcodes namespace and package, GetBarcodeAsync is now GetBarcode and returns PNG, and reading an image file is the ReadBarcode(byte[]) extension in CheapHelpers.Services. ImageSharp is gone and NetVips (with NetVips.Native) now comes in through CheapHelpers.Services, so an app that referenced ImageSharp only for this library can drop it. NetVips.Native has no BMP loader, so ReadBarcode(byte[]) rejects BMP files and uploads keep BMP files as they are instead of converting them to JPEG. CheapHelpers.Blazor no longer depends on Blazored.LocalStorage, so an app that registered it only for this library can remove the AddBlazoredLocalStorage call. BlobService uploads now store the content type and GetFileUri no longer writes blob headers, so blobs uploaded before 4.0.0 keep whatever content type they have (browsers sniff images, and a one-off SetHttpHeaders pass fixes any that matter). GetFileUri and GetFile no longer contact storage at all, so a file name whose blob is missing now gets a URL that returns 404 instead of the placeholder. ProgressButton no longer needs AddCheapHelpersBlazor for its localizer, so it works in an app that only calls AddMudServices. FileSettingsService now throws an InvalidOperationException on a stored value it cannot deserialise instead of returning the default, and setting OnCorruptValue = CorruptValueHandling.ReturnDefault in AddFileSettingsService brings the old behaviour back. IBackgroundTaskQueue.QueueBackgroundWorkItemAsync and GetExecutableVersionAsync on both executable detection services take a trailing CancellationToken, and the BackgroundTaskQueue capacity is now an int? where null means unbounded, so implementations of the interface need the new parameter and existing binaries need a rebuild. The Plex, OAuth, OpenID Connect and API key types moved from CheapHelpers.Services and CheapHelpers.Blazor to the CheapHelpers.Auth package, under the CheapHelpers.Auth, CheapHelpers.Auth.Plex, CheapHelpers.Auth.OAuth and CheapHelpers.Auth.ApiKeys namespaces; the AddXxxAuth, MapPlexAuthEndpoints, MapOAuthEndpoints, AddCheapApiKeys and UseCheapApiKeyMiddleware methods keep their names and now live in the CheapHelpers.Auth namespace. CheapHelpers.Blazor references CheapHelpers.Auth, and since CheapHelpers.Auth does not depend on CheapHelpers.Services, an app that only needs Plex or OAuth sign-in without the Blazor components can reference CheapHelpers.Auth alone. CsvService exports now prefix cells that start with =, +, -, @, tab or CR and are not plain numbers with a ' to stop spreadsheet formula injection.

CheapHelpers.Blazor 4.0.1 fixes a startup crash in published apps that have a wwwroot file with the same relative name as one of the package's assets, for example js/site.js. The static asset manifest format that 4.0.0 shipped with caused it. Apps on 4.0.0 can rename the clashing file or upgrade.

To force a password change, let AfterSignIn return routes.ChangePasswordRoute + "?forced=true" when your own check says the user needs a new password. The change-password page then shows a warning that a new password is required. Keeping the user on that page until the password is changed stays in your app, for example in middleware that redirects other requests.

Passkeys appear on the login page and in the Security tab of the account page once the app sets Stores.SchemaVersion = IdentitySchemaVersions.Version3 in its Identity options. Add one EF migration for the new AspNetUserPasskeys table. Nothing else is needed, because the package loads its passkey script on every page itself.

Entity Framework

// Generic context with Identity
services.AddCheapContext<MyUser>(options => options.UseSqlServer(connectionString))
    .AddIdentity<IdentityRole>();

// UserRepo<TUser, TContext> needs Identity's hasher, normalizer, options and error describer, so don't construct it:
// inject UserService<TUser, TContext> (a UserRepo, registered by AddCheapHelpersBlazor) from DI
var userRepo = serviceProvider.GetRequiredService<UserService<MyUser, CheapContext<MyUser>>>();

var users = await userRepo.GetAllUsersPaginatedAsync(pageIndex: 1, pageSize: 20);
var stats = await userRepo.GetUserStatisticsAsync();

Scheduling

using CheapHelpers.Scheduling;

scheduledTaskService.RegisterDailyTask("cleanup", new TimeOnly(2, 0), async ct =>
{
    await notificationService.DeleteExpiredAsync(ct);
});

scheduledTaskService.RegisterMonthlyTask("billing", 1, new TimeOnly(3, 0), async ct =>
{
    await billingService.RunBillingCycleAsync(ct);
});

Network Scanning

services.AddNetworkScanning()
    .AddAllDetectors()  // UPnP, mDNS, HTTP, SSH
    .AddJsonStorage();

var scanner = serviceProvider.GetRequiredService<INetworkScanner>();
scanner.DeviceDiscovered += (device) =>
    Debug.WriteLine($"Found: {device.Name} ({device.IPv4Address})");
scanner.Start();

Project Structure

CheapHelpers/                      Core utilities, extensions, scheduling, threading
CheapHelpers.Models/               Entities, DTOs, enums, UBL invoice models
CheapHelpers.EF/                   Entity Framework, CheapContext, repositories
CheapHelpers.Auth/                 Plex, OAuth/OIDC, shared secret, forwarded headers, Data Protection
  ├── ApiKeys/                     API key generation, validation, rate-limiting middleware
  ├── OAuth/                       Google, Microsoft, GitHub, Apple, OpenID Connect options
  └── Plex/                        Plex PIN sign-in service
CheapHelpers.Services/
  ├── Billing/                     Usage metering, invoice generation, billing cycles
  ├── DataExchange/
  │   ├── Csv/                     CSV import/export
  │   ├── Excel/                   Excel generation (ClosedXML)
  │   ├── Json/                    JSON file service
  │   ├── Pdf/                     PDF generation (iText) + optimization (iLovePDF)
  │   ├── Ubl/                     UBL Order/Invoice/CreditNote (UblSharp, PEPPOL BIS 3.0)
  │   └── Xml/                     XML serialization
  ├── Email/                       MailKit, Microsoft Graph, SendGrid + Fluid templates
  ├── Geocoding/                   Mapbox, Azure Maps, Google Maps, PTV Maps
  ├── Notifications/               Multi-channel (InApp, Email, SMS, Push) + RabbitMQ
  ├── Polling/                     HTTP polling with exponential backoff
  ├── Reporting/                   Report generation, storage, distribution, scheduling
  └── Storage/                     Azure Blob Storage
CheapHelpers.Blazor/
  ├── Components/                  NotificationBell, CultureSelector
  ├── Extensions/                  DI, account, culture and notification endpoint mapping
  ├── Helpers/                     AuthenticatorHelper, AccountTabDefinition
  ├── Hybrid/                      WebView bridge, push notifications, app bar
  ├── Pages/Account/               Full account module (login, register, 2FA, GDPR)
  ├── Services/                    UserService, ExternalUserProvisioner, RabbitMQ consumer
  └── Shared/                      LoginDisplay, PlexSignInCard, UploadFile, ImagePanel, Selectors
CheapHelpers.Networking/           Network scanner, mDNS discovery, device detectors
CheapHelpers.MediaProcessing/      FFmpeg integration, hardware detection (Win/Linux)
CheapHelpers.MAUI/                 iOS APNS, Android FCM, status bar helpers
CheapHelpers.Avalonia.Bridge/      Desktop notification adapter

Key Dependencies

Package Used For
MailKit SMTP email delivery
Microsoft.Graph Microsoft Graph email
SendGrid SendGrid email API
Fluid.Core Liquid email templates
iText + iText.PdfOptimizer PDF generation and optimization
ILove_PDF Cloud PDF optimization
UblSharp UBL 2.1 document generation (PEPPOL BIS 3.0)
RabbitMQ.Client Cross-server notification transport
MudBlazor Blazor UI components
ClosedXML Excel file generation
Humanizer.Core Relative timestamp formatting
FluentValidation Form validation
Azure.Storage.Blobs Report/file storage
Azure.Identity + Microsoft.Graph Azure AD authentication
Microsoft.Azure.NotificationHubs Push notification backend

Requirements

  • .NET 11.0 or later
  • C# 15

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

git clone https://git.cheapludes.be/cheapnud/CheapHelpers.git
cd CheapHelpers
dotnet restore
dotnet build

License

This project is licensed under the MIT License - see the LICENSE.txt file for details.