⚠️ AI-Assisted Development NoticeThis project contains code generated with assistance from Large Language Models (LLMs), including Claude (Anthropic) and Gemini (Google). All code—whether explicitly marked or not—should be considered experimental. The AI assistance was used for code generation, architecture decisions, debugging, documentation, and test creation. Human review and testing have been applied, but users should exercise appropriate caution when deploying to production environments.
A lightweight, self-hosted blogging platform built with .NET 10 and Blazor Server, following Clean Architecture principles.
Live Demo: kush.runasp.net
- Overview
- Features
- Architecture
- Technology Stack
- Quick Start
- Configuration
- Database
- Authentication & Security
- Content Management
- Markdown Specification
- Image Management
- Real-Time Features
- Theming System
- Observability & Telemetry
- API Reference
- Admin Guide
- Testing
- Deployment
- Troubleshooting
- Contributing
- License
MyBlog is a complete content management system designed for developers who want full control over their blogging platform. It prioritizes simplicity, security, and self-sufficiency—requiring no external services, JavaScript frameworks, or CSS libraries.
| Principle | Implementation |
|---|---|
| Zero External Dependencies | No npm, Node.js, or CSS frameworks. Pure .NET with custom CSS. |
| Self-Contained | Single deployable unit with SQLite database. No external DB servers. |
| Cross-Platform | Runs identically on Windows, Linux, and macOS. |
| Security First | Rate limiting, secure password hashing, input sanitization. |
| Observable | Built-in OpenTelemetry with file and database logging. |
| Testable | Interface-based DI with comprehensive unit and integration tests. |
- Markdown Authoring — Write posts in Markdown with live preview
- Custom Markdown Parser — No external libraries; supports all common syntax
- Automatic Image Dimensions — Fetches and caches dimensions to prevent layout shift
- SEO Optimization — Open Graph, Twitter Cards, JSON-LD structured data
- Social Sharing — Web Share API with clipboard fallback
- Progressive Rate Limiting — Slows down brute-force attacks but never locks users out
- Secure Password Storage — ASP.NET Identity PasswordHasher with automatic rehashing
- Slug Collision Prevention — Automatic unique slug generation
- Cookie-Based Sessions — HttpOnly, secure cookies with sliding expiration
- Multi-User Support — Create and manage multiple authors
- Image Library — Upload, browse, and manage images stored in the database
- Real-Time Reader Counts — See how many people are reading each post
- Dashboard — Overview of posts, quick access to all management areas
- Six Color Themes — Light, Dark, Sepia, Nord, Solarized Light, Dracula
- OpenTelemetry Integration — Distributed tracing, metrics, and logging
- Automatic Log Cleanup — Configurable retention with daily cleanup
- XDG-Compliant Paths — Proper data storage locations per platform
- CI/CD Pipeline — GitHub Actions with cross-platform testing
MyBlog follows Clean Architecture (also known as Onion Architecture), ensuring clear separation of concerns and testability.
┌─────────────────────────────────────────────────────────────────┐
│ MyBlog.Web │
│ (Presentation Layer) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
│ │ Pages/ │ │ Shared/ │ │ Middleware/ │ │ Hubs/ │ │
│ │ Components │ │ Components │ │Rate Limiting│ │ SignalR │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ MyBlog.Infrastructure │
│ (Data Access Layer) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
│ │Repositories │ │ Services │ │ Telemetry │ │ Data/ │ │
│ │ (EF Core) │ │Auth,Password│ │ Exporters │ │ DbContext │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ MyBlog.Core │
│ (Domain Layer) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
│ │ Models │ │ Interfaces │ │ Services │ │ Constants │ │
│ │Post,User,.. │ │IPostRepo,.. │ │Markdown,Slug│ │ AppConsts │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
MyBlog/
├── src/
│ ├── MyBlog.Core/ # Domain Layer (no external dependencies)
│ │ ├── Constants/
│ │ │ └── AppConstants.cs # Auth cookie name, roles, limits
│ │ ├── Interfaces/
│ │ │ ├── IAuthService.cs
│ │ │ ├── IImageDimensionService.cs
│ │ │ ├── IImageRepository.cs
│ │ │ ├── IMarkdownService.cs
│ │ │ ├── IPasswordService.cs
│ │ │ ├── IPostRepository.cs
│ │ │ ├── IReaderTrackingService.cs
│ │ │ ├── ISlugService.cs
│ │ │ ├── ITelemetryLogRepository.cs
│ │ │ └── IUserRepository.cs
│ │ ├── Models/
│ │ │ ├── Image.cs
│ │ │ ├── ImageDimensionCache.cs
│ │ │ ├── Post.cs
│ │ │ ├── PostDto.cs
│ │ │ ├── TelemetryLog.cs
│ │ │ └── User.cs
│ │ └── Services/
│ │ ├── MarkdownService.cs
│ │ └── SlugService.cs
│ │
│ ├── MyBlog.Infrastructure/ # Data Access Layer
│ │ ├── Data/
│ │ │ ├── BlogDbContext.cs
│ │ │ ├── DatabasePathResolver.cs
│ │ │ └── DatabaseSchemaUpdater.cs
│ │ ├── Repositories/
│ │ │ ├── ImageRepository.cs
│ │ │ ├── PostRepository.cs
│ │ │ ├── TelemetryLogRepository.cs
│ │ │ └── UserRepository.cs
│ │ ├── Services/
│ │ │ ├── AuthService.cs
│ │ │ ├── ImageCacheWarmerService.cs
│ │ │ ├── ImageDimensionService.cs
│ │ │ ├── PasswordService.cs
│ │ │ ├── ReaderTrackingService.cs
│ │ │ └── TelemetryCleanupService.cs
│ │ ├── Telemetry/
│ │ │ ├── DatabaseLogExporter.cs
│ │ │ ├── FileLogExporter.cs
│ │ │ └── TelemetryPathResolver.cs
│ │ └── ServiceCollectionExtensions.cs
│ │
│ ├── MyBlog.Web/ # Presentation Layer
│ │ ├── Components/
│ │ │ ├── Layout/
│ │ │ │ └── MainLayout.razor
│ │ │ ├── Pages/
│ │ │ │ ├── Admin/
│ │ │ │ │ ├── ChangePassword.razor
│ │ │ │ │ ├── Dashboard.razor
│ │ │ │ │ ├── ImageManager.razor
│ │ │ │ │ ├── PostEditor.razor
│ │ │ │ │ ├── PostList.razor
│ │ │ │ │ ├── UserEditor.razor
│ │ │ │ │ └── UserList.razor
│ │ │ │ ├── About.razor
│ │ │ │ ├── AccessDenied.razor
│ │ │ │ ├── Home.razor
│ │ │ │ ├── Login.razor
│ │ │ │ └── PostDetail.razor
│ │ │ └── Shared/
│ │ │ ├── Footer.razor
│ │ │ ├── MarkdownRenderer.razor
│ │ │ ├── Pagination.razor
│ │ │ ├── PostCard.razor
│ │ │ ├── ReaderBadge.razor
│ │ │ ├── RedirectToLogin.razor
│ │ │ └── ThemeSwitcher.razor
│ │ ├── Hubs/
│ │ │ └── ReaderHub.cs
│ │ ├── Middleware/
│ │ │ └── LoginRateLimitMiddleware.cs
│ │ └── wwwroot/
│ │ ├── css/site.css
│ │ └── js/site.js
│ │
│ ├── MyBlog.Tests/ # Test Project
│ │ ├── Integration/
│ │ │ ├── AuthServiceLongPasswordTests.cs
│ │ │ ├── AuthServiceTests.cs
│ │ │ ├── PasswordChangeTests.cs
│ │ │ ├── PostRepositoryTests.cs
│ │ │ └── TelemetryCleanupTests.cs
│ │ └── Unit/
│ │ ├── LoginRateLimitMiddlewareTests.cs
│ │ ├── MarkdownServiceTests.cs
│ │ ├── PasswordServiceTests.cs
│ │ └── SlugServiceTests.cs
│ │
│ ├── Directory.Build.props # Shared build properties
│ ├── Directory.Packages.props # Centralized package versions
│ └── MyBlog.slnx # Solution file
│
├── .github/
│ └── workflows/
│ └── build-deploy.yml # CI/CD pipeline
│
└── README.md
| Component | Technology | Version |
|---|---|---|
| Runtime | .NET | 10.0 |
| Web Framework | ASP.NET Core | 10.0 |
| UI Framework | Blazor Server | 10.0 |
| ORM | Entity Framework Core | 10.0.2 |
| Real-Time | SignalR | 10.0.2 |
| Component | Technology |
|---|---|
| Database Engine | SQLite |
| Storage | Single file, XDG-compliant paths |
| Images | Binary BLOBs in database |
| Component | Technology | Version |
|---|---|---|
| Telemetry | OpenTelemetry | 1.15.0 |
| Tracing | OpenTelemetry.Instrumentation.AspNetCore | 1.15.0 |
| Logging | File (JSON) + Database + Console | — |
| Component | Technology | Version |
|---|---|---|
| Framework | xUnit | v3.2.2 |
| Test SDK | Microsoft.NET.Test.Sdk | 18.0.1 |
| Database | In-Memory SQLite | — |
| Component | Implementation |
|---|---|
| Password Hashing | ASP.NET Identity PasswordHasher (PBKDF2) |
| Authentication | Cookie-based with sliding expiration |
| Rate Limiting | Custom middleware with progressive delays |
- .NET 10 SDK or later
# Clone the repository
git clone https://github.com/kusl/dotnetcms.git
cd dotnetcms/src
# Restore dependencies
dotnet restore MyBlog.slnx
# Run the application
cd MyBlog.Web
dotnet runThe application starts at https://localhost:51226 (or the next available port).
| Field | Value |
|---|---|
| Username | admin |
| Password | ChangeMe123! |
⚠️ Important: The default password is only used when creating the initial admin user. Once the user exists, you must change the password through the website at/admin/change-password.
cd src
dotnet test MyBlog.slnxOr run the test project directly:
dotnet run --project src/MyBlog.Tests/MyBlog.Tests.csprojMyBlog uses the standard ASP.NET Core configuration system with the following files:
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=myblog.db"
},
"Authentication": {
"SessionTimeoutMinutes": 30,
"DefaultAdminPassword": "ChangeMe123!"
},
"Telemetry": {
"RetentionDays": 30,
"EnableFileLogging": true,
"EnableDatabaseLogging": true
},
"Application": {
"Title": "MyBlog",
"PostsPerPage": 10,
"RequireHttps": false,
"GitForgeUrl": "https://github.com/kusl/dotnetcms"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}| Key | Type | Default | Description |
|---|---|---|---|
Application:Title |
string | "MyBlog" |
Site title displayed in header, browser tabs, and metadata |
Application:PostsPerPage |
int | 10 |
Number of posts per page on the homepage |
Application:RequireHttps |
bool | false |
Force HTTPS for authentication cookies |
Application:GitForgeUrl |
string | — | Link to source code repository displayed in footer |
| Key | Type | Default | Description |
|---|---|---|---|
Authentication:SessionTimeoutMinutes |
int | 30 |
Session expiration time in minutes |
Authentication:DefaultAdminPassword |
string | "ChangeMe123!" |
Initial admin password (first run only) |
| Key | Type | Default | Description |
|---|---|---|---|
Telemetry:RetentionDays |
int | 30 |
Days to retain telemetry logs before cleanup |
Telemetry:EnableFileLogging |
bool | true |
Write logs to JSON files |
Telemetry:EnableDatabaseLogging |
bool | true |
Store logs in SQLite database |
Environment variables override configuration file values:
| Variable | Description | Overrides |
|---|---|---|
MYBLOG_ADMIN_PASSWORD |
Initial admin password | Authentication:DefaultAdminPassword |
ASPNETCORE_ENVIRONMENT |
Runtime environment (Development, Production) |
— |
XDG_DATA_HOME |
Linux data directory override | Database path |
Priority order: Environment Variables > appsettings.{Environment}.json > appsettings.json
MyBlog uses SQLite with Entity Framework Core. The schema is created automatically on first run.
CREATE TABLE "Users" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY,
"Username" TEXT NOT NULL,
"PasswordHash" TEXT NOT NULL,
"Email" TEXT NOT NULL,
"DisplayName" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL
);
CREATE UNIQUE INDEX "IX_Users_Username" ON "Users" ("Username");| Column | Type | Constraints | Description |
|---|---|---|---|
| Id | GUID | Primary Key | Unique identifier |
| Username | VARCHAR(50) | Unique, Required | Login username (case-insensitive lookup) |
| PasswordHash | VARCHAR(256) | Required | PBKDF2 hash from ASP.NET Identity |
| VARCHAR(256) | Required | User's email address | |
| DisplayName | VARCHAR(100) | Required | Name shown on posts |
| CreatedAtUtc | DateTime | Required | Account creation timestamp |
CREATE TABLE "Posts" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY,
"Title" TEXT NOT NULL,
"Slug" TEXT NOT NULL,
"Content" TEXT NOT NULL,
"Summary" TEXT NOT NULL,
"AuthorId" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL,
"PublishedAtUtc" TEXT,
"IsPublished" INTEGER NOT NULL,
CONSTRAINT "FK_Posts_Users_AuthorId" FOREIGN KEY ("AuthorId")
REFERENCES "Users" ("Id") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX "IX_Posts_Slug" ON "Posts" ("Slug");| Column | Type | Constraints | Description |
|---|---|---|---|
| Id | GUID | Primary Key | Unique identifier |
| Title | VARCHAR(200) | Required | Post title |
| Slug | VARCHAR(200) | Unique, Required | URL-friendly identifier |
| Content | TEXT | Required | Markdown content |
| Summary | VARCHAR(500) | Required | Brief description for listings |
| AuthorId | GUID | Foreign Key | Reference to Users table |
| CreatedAtUtc | DateTime | Required | Creation timestamp |
| UpdatedAtUtc | DateTime | Required | Last modification timestamp |
| PublishedAtUtc | DateTime | Nullable | Publication timestamp |
| IsPublished | Boolean | Required | Visibility flag |
CREATE TABLE "Images" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Images" PRIMARY KEY,
"FileName" TEXT NOT NULL,
"ContentType" TEXT NOT NULL,
"Data" BLOB NOT NULL,
"PostId" TEXT,
"UploadedAtUtc" TEXT NOT NULL,
"UploadedByUserId" TEXT NOT NULL,
CONSTRAINT "FK_Images_Posts_PostId" FOREIGN KEY ("PostId")
REFERENCES "Posts" ("Id") ON DELETE SET NULL,
CONSTRAINT "FK_Images_Users_UploadedByUserId" FOREIGN KEY ("UploadedByUserId")
REFERENCES "Users" ("Id") ON DELETE RESTRICT
);| Column | Type | Constraints | Description |
|---|---|---|---|
| Id | GUID | Primary Key | Unique identifier |
| FileName | VARCHAR(256) | Required | Original file name |
| ContentType | VARCHAR(100) | Required | MIME type (image/jpeg, etc.) |
| Data | BLOB | Required | Binary image data |
| PostId | GUID | Nullable, FK | Optional association to a post |
| UploadedAtUtc | DateTime | Required | Upload timestamp |
| UploadedByUserId | GUID | Foreign Key | Reference to uploader |
CREATE TABLE "ImageDimensionCache" (
"Url" TEXT NOT NULL CONSTRAINT "PK_ImageDimensionCache" PRIMARY KEY,
"Width" INTEGER NOT NULL,
"Height" INTEGER NOT NULL,
"LastCheckedUtc" TEXT NOT NULL
);| Column | Type | Constraints | Description |
|---|---|---|---|
| Url | VARCHAR(2048) | Primary Key | Image URL (external images) |
| Width | Integer | Required | Cached width in pixels |
| Height | Integer | Required | Cached height in pixels |
| LastCheckedUtc | DateTime | Required | When dimensions were fetched |
CREATE TABLE "TelemetryLogs" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TelemetryLogs" PRIMARY KEY AUTOINCREMENT,
"TimestampUtc" TEXT NOT NULL,
"Level" TEXT NOT NULL,
"Category" TEXT NOT NULL,
"Message" TEXT NOT NULL,
"Exception" TEXT,
"TraceId" TEXT,
"SpanId" TEXT,
"Properties" TEXT
);
CREATE INDEX "IX_TelemetryLogs_TimestampUtc" ON "TelemetryLogs" ("TimestampUtc");| Column | Type | Constraints | Description |
|---|---|---|---|
| Id | Integer | Auto-increment PK | Unique identifier |
| TimestampUtc | DateTime | Required, Indexed | Log timestamp |
| Level | VARCHAR(20) | Required | Log level (Information, Warning, Error) |
| Category | VARCHAR(256) | Required | Logger category/source |
| Message | TEXT | Required | Log message |
| Exception | TEXT | Nullable | Exception details if any |
| TraceId | TEXT | Nullable | Distributed trace ID |
| SpanId | TEXT | Nullable | Span ID within trace |
| Properties | TEXT | Nullable | Additional properties as JSON |
The database file location follows platform conventions:
| Platform | Path |
|---|---|
| Linux | ~/.local/share/MyBlog/myblog.db |
| macOS | ~/Library/Application Support/MyBlog/myblog.db |
| Windows | %LOCALAPPDATA%\MyBlog\myblog.db |
| Fallback | {AppDirectory}/data/myblog.db |
The system automatically:
- Checks for XDG_DATA_HOME environment variable (Linux)
- Falls back to platform-specific standard directories
- Falls back to local
data/directory if the preferred location is not writable
On application startup:
- EnsureCreated — Creates the database and all tables if they don't exist
- DatabaseSchemaUpdater.ApplyUpdatesAsync — Adds new tables to existing databases (for upgrades)
- AuthService.EnsureAdminUserAsync — Creates default admin user if no users exist
Since the project doesn't use formal EF Core migrations, the DatabaseSchemaUpdater class handles incremental schema updates:
public static async Task ApplyUpdatesAsync(BlogDbContext db)
{
// Check and create ImageDimensionCache table if it doesn't exist
await EnsureImageDimensionCacheTableAsync(db);
}This approach is idempotent—safe to run multiple times without side effects.
┌─────────┐ ┌─────────────────┐ ┌─────────────┐
│ User │───▶│ Rate Limiting │───▶│ Login │
│ │ │ Middleware │ │ Page │
└─────────┘ └─────────────────┘ └─────────────┘
│ │
│ Delay if needed │ POST /login
▼ ▼
┌─────────────────┐ ┌─────────────┐
│ Track Attempt │ │ AuthService │
│ (per IP) │ │.Authenticate│
└─────────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ Password │
│ Service │
│ .Verify │
└─────────────┘
│
Success ◄───────┴───────► Failure
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Create │ │ Show Error │
│ Claims & │ │ Message │
│ Sign In │ │ │
└─────────────┘ └─────────────┘
Passwords are hashed using ASP.NET Identity's PasswordHasher<T>:
Algorithm: PBKDF2 with HMAC-SHA256
- 128-bit salt
- 256-bit subkey
- 10,000+ iterations (version-dependent)
public sealed class PasswordService : IPasswordService
{
private readonly PasswordHasher<User> _hasher = new();
public string HashPassword(string password)
{
return _hasher.HashPassword(null!, password);
}
public bool VerifyPassword(string hashedPassword, string providedPassword)
{
var result = _hasher.VerifyHashedPassword(null!, hashedPassword, providedPassword);
return result == PasswordVerificationResult.Success ||
result == PasswordVerificationResult.SuccessRehashNeeded;
}
}Key behaviors:
- Same password produces different hashes each time (random salt)
- Automatic rehashing detection for algorithm upgrades
- Supports passwords up to 512+ characters
The LoginRateLimitMiddleware protects against brute-force attacks using progressive delays:
| Attempt # | Delay |
|---|---|
| 1-5 | None |
| 6 | 1 second |
| 7 | 2 seconds |
| 8 | 4 seconds |
| 9 | 8 seconds |
| 10 | 16 seconds |
| 11+ | 30 seconds (max) |
Key design decisions:
- Never blocks users — Only delays, ensuring legitimate users can always try again
- Per-IP tracking — Each IP address has independent attempt counters
- 15-minute window — Counters reset after 15 minutes of inactivity
- Testable — Delay function is injectable for unit testing
// Delay calculation formula
var delayMultiplier = record.Count - AttemptsBeforeDelay; // attempts - 5
var delaySeconds = Math.Min(Math.Pow(2, delayMultiplier), MaxDelaySeconds); // 2^n, max 30Sessions use cookie-based authentication:
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "MyBlog.Auth";
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.AccessDeniedPath = "/access-denied";
options.ExpireTimeSpan = TimeSpan.FromMinutes(30); // Configurable
options.SlidingExpiration = true;
options.Cookie.HttpOnly = true; // XSS protection
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; // Or Always for HTTPS
});When a user logs in, the following claims are created:
| Claim Type | Value |
|---|---|
ClaimTypes.NameIdentifier |
User's GUID |
ClaimTypes.Name |
Username |
DisplayName |
User's display name |
ClaimTypes.Role |
"Admin" |
┌─────────────────────────────────────────────────────────────────┐
│ Post Creation │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────┐ Generate ┌─────────────┐ Check ┌─────────────┐
│ User enters │──────────▶│ SlugService │─────────▶│ IsSlugTaken │
│ Title │ Slug │ .Generate │ Unique? │ Repository │
└─────────────┘ └─────────────┘ └─────────────┘
│
┌───────────────────────────────┤
│ │
Unique Not Unique
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Use slug │ │ Append -1, │
│ as-is │ │ -2, etc. │
└─────────────┘ └─────────────┘
│ │
└───────────────┬───────────────┘
▼
┌─────────────┐
│ Save Post │
│ to Database │
└─────────────┘
The SlugService converts titles to URL-friendly slugs:
public string GenerateSlugOrUuid(string title)
{
var slug = GenerateSlug(title);
return !string.IsNullOrWhiteSpace(slug)
? slug
: $"post-{Guid.CreateVersion7().ToString()}";
}
private string GenerateSlug(string title)
{
// 1. Normalize Unicode (decompose accented characters)
var normalized = title.Normalize(NormalizationForm.FormD);
// 2. Remove diacritical marks
// 3. Convert to lowercase
// 4. Replace spaces/underscores with hyphens
// 5. Remove non-alphanumeric characters (except hyphens)
// 6. Collapse multiple hyphens
// 7. Trim hyphens from ends
}Examples:
| Input | Output |
|---|---|
"Hello World" |
hello-world |
"Hello, World! How's it going?" |
hello-world-hows-it-going |
"Café résumé" |
cafe-resume |
"Top 10 Tips for 2024" |
top-10-tips-for-2024 |
"hello_world_test" |
hello-world-test |
"" (empty) |
post-{guid} |
" " (whitespace only) |
post-{guid} |
The system uses DTOs to separate concerns:
// For list views (lightweight)
public sealed record PostListItemDto(
Guid Id,
string Title,
string Slug,
string Summary,
string AuthorDisplayName,
DateTime? PublishedAtUtc,
bool IsPublished);
// For detail views (full content)
public sealed record PostDetailDto(
Guid Id,
string Title,
string Slug,
string Content,
string Summary,
string AuthorDisplayName,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? PublishedAtUtc,
bool IsPublished);
// For creating posts
public sealed record CreatePostDto(
string Title,
string Content,
string Summary,
bool IsPublished);
// For updating posts
public sealed record UpdatePostDto(
string Title,
string Content,
string Summary,
bool IsPublished);MyBlog uses a custom-built Markdown parser with no external dependencies. This section documents the exact specification for compatibility.
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6Output:
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<!-- etc. -->**bold text**
__also bold__Output:
<strong>bold text</strong>
<strong>also bold</strong>*italic text*
_also italic_Output:
<em>italic text</em>
<em>also italic</em>[Link Text](https://example.com)Output:
<a href="https://example.com">Link Text</a>Output (with dimension lookup):
<img src="https://example.com/image.png" alt="Alt Text" width="800" height="600" />Output (without dimensions):
<img src="https://example.com/image.png" alt="Alt Text" />Use `code` hereOutput:
Use <code>code</code> here```
var x = 1;
console.log(x);
```Output:
<pre><code>var x = 1;
console.log(x);</code></pre>Note: Language specification after the backticks is accepted but not used for syntax highlighting.
> This is a quoteOutput:
<blockquote><p>This is a quote</p></blockquote>- Item 1
- Item 2
- Item 3or
* Item 1
* Item 2Output:
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>1. First
2. Second
3. ThirdOutput:
<ol>
<li>First</li>
<li>Second</li>
<li>Third</li>
</ol>---or *** or ___ (3+ characters)
Output:
<hr />Regular text separated by blank lines becomes paragraphs:
This is paragraph one.
This is paragraph two.Output:
<p>This is paragraph one.</p>
<p>This is paragraph two.</p>The Markdown service automatically fetches and caches dimensions for external images:
- On render: Check
ImageDimensionCachetable for URL - If cached: Use stored width/height
- If not cached: Fetch image headers, parse dimensions, store in cache
- If fetch fails: Render image without dimensions (graceful degradation)
Supported image formats for dimension detection:
- PNG (dimensions at bytes 16-23)
- GIF (dimensions at bytes 6-9)
- JPEG (requires scanning for SOF marker)
- WebP (VP8, VP8L, VP8X variants)
All user content is HTML-escaped before processing:
text = HttpUtility.HtmlEncode(text);This prevents XSS attacks while allowing Markdown syntax.
| Specification | Value |
|---|---|
| Maximum Size | 5 MB (5,242,880 bytes) |
| Allowed Types | image/jpeg, image/png, image/gif, image/webp |
| Storage | Binary BLOB in SQLite database |
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ InputFile │───▶│ Validate Size │───▶│ Validate │
│ Component │ │ (< 5MB) │ │ Content Type│
└─────────────┘ └─────────────────┘ └─────────────┘
│
▼
┌─────────────────┐ ┌─────────────┐
│ Save to │◀───│ Read to │
│ Database │ │ MemoryStream│
└─────────────────┘ └─────────────┘
After uploading, reference images in Markdown using:
Example:
GET /api/images/{id}
Returns the image binary data with appropriate Content-Type header.
Response Headers:
Content-Type: image/jpeg
Content-Length: 12345
MyBlog tracks how many users are currently reading each post using SignalR.
┌──────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────┐ │
│ │ ReaderBadge │◀──────SignalR Connection──────────────────────┐│
│ │ Component │ ││
│ └─────────────┘ ││
└─────────────────────────────────────────────────────────────────┘│
│
┌─────────────────────────────────────────────────────────────────┐│
│ Server ││
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
│ │ ReaderHub │───▶│ Tracking │───▶│ Concurrent │ ││
│ │ (SignalR) │ │ Service │ │ Dictionary │ ││
│ └─────────────┘ └─────────────┘ └─────────────┘ ││
│ │ │ ││
│ └──────────Broadcast Count──────────────┘◀─────────────┘│
└──────────────────────────────────────────────────────────────────┘
public class ReaderHub : Hub
{
// Client calls when viewing a post
public async Task JoinPage(string slug);
// Client calls when leaving a post
public async Task LeavePage(string slug);
// Automatically called on disconnect
public override async Task OnDisconnectedAsync(Exception? exception);
}| Event | Direction | Payload | Description |
|---|---|---|---|
JoinPage |
Client → Server | string slug |
Register viewer for post |
LeavePage |
Client → Server | string slug |
Unregister viewer |
UpdateCount |
Server → Client | int count |
Broadcast new count |
public class ReaderTrackingService : IReaderTrackingService
{
// Maps Slug → Count of active readers
private readonly ConcurrentDictionary<string, int> _slugCounts = new();
// Maps ConnectionId → Slug (for disconnect handling)
private readonly ConcurrentDictionary<string, string> _connectionMap = new();
}Thread-safety: All operations use ConcurrentDictionary with atomic operations.
| Theme | Type | Description |
|---|---|---|
light |
Light | Clean, professional default |
dark |
Dark | Easy on the eyes for night reading |
sepia |
Light | Warm, paper-like reading experience |
nord |
Dark | Inspired by Arctic landscapes |
solarized-light |
Light | Ethan Schoonover's classic palette |
dracula |
Dark | Popular dark theme with vibrant accents |
Each theme defines the following CSS custom properties:
:root, [data-theme="light"] {
--color-bg: #ffffff;
--color-bg-alt: #f8f9fa;
--color-bg-elevated: #ffffff;
--color-text: #1a1a2e;
--color-text-muted: #5a5a6e;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-muted: #3b82f6;
--color-border: #d1d5db;
--color-border-light: #e5e7eb;
--color-danger: #dc2626;
--color-danger-hover: #b91c1c;
--color-success: #059669;
--color-success-hover: #047857;
--color-warning: #d97706;
--color-info: #0891b2;
--color-code-bg: #f1f5f9;
--color-blockquote-border: #3b82f6;
--color-selection-bg: #bfdbfe;
--color-selection-text: #1e3a5f;
--color-focus-ring: #3b82f6;
--color-shadow: rgba(0, 0, 0, 0.1);
--color-overlay: rgba(0, 0, 0, 0.5);
color-scheme: light;
}Themes are stored in localStorage:
localStorage.setItem('myblog-theme', 'dark');The system automatically detects and respects the user's OS preference:
window.matchMedia('(prefers-color-scheme: dark)').matchesIf no theme is saved, the system preference is used.
A blocking inline script runs before page render to prevent theme flash:
(function() {
var theme = localStorage.getItem('myblog-theme');
if (!theme) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
})();MyBlog uses OpenTelemetry for distributed tracing, metrics, and logging.
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(serviceName: "MyBlog.Web", serviceVersion: "1.0.0"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("MyBlog.Web")
.AddConsoleExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddConsoleExporter());Writes JSON-formatted logs to rotating files:
~/.local/share/MyBlog/telemetry/logs/logs_20260126_063830.json
Format:
[
{
"Timestamp": "2026-01-26T06:38:30.1234567Z",
"Level": "Information",
"Category": "MyBlog.Web.Pages.Home",
"Message": "Page loaded",
"TraceId": "abc123...",
"SpanId": "def456...",
"Exception": null
},
...
]Rotation: Files rotate at 25 MB with sequential numbering.
Writes structured logs to the TelemetryLogs table for queryable storage.
The TelemetryCleanupService runs daily to remove old logs:
public sealed class TelemetryCleanupService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run cleanup immediately on startup
await CleanupAsync(stoppingToken);
// Then run daily
using var timer = new PeriodicTimer(TimeSpan.FromDays(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await CleanupAsync(stoppingToken);
}
}
}Retention: Controlled by Telemetry:RetentionDays (default: 30 days).
Homepage with paginated published posts.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
int | 1 | Page number |
View a single published post.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
slug |
string | Post's URL slug |
Response: HTML page with SEO metadata, Open Graph tags, and JSON-LD structured data.
About page with application information.
Login page.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
returnUrl |
string | URL to redirect after login |
Authenticate user (handled by Blazor form).
Form Data:
| Field | Type | Required |
|---|---|---|
username |
string | Yes |
password |
string | Yes |
Sign out current user. Requires authentication.
Retrieve an uploaded image.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
GUID | Image identifier |
Response:
200 OKwith image binary andContent-Typeheader404 Not Foundif image doesn't exist
| Path | Description |
|---|---|
/admin |
Dashboard |
/admin/posts |
Post list |
/admin/posts/new |
Create post |
/admin/posts/edit/{id} |
Edit post |
/admin/users |
User list |
/admin/users/new |
Create user |
/admin/users/edit/{id} |
Edit user |
/admin/images |
Image manager |
/admin/change-password |
Change own password |
Endpoint: /readerHub
| Method | Direction | Parameters | Description |
|---|---|---|---|
JoinPage |
Client → Server | string slug |
Start tracking for a post |
LeavePage |
Client → Server | string slug |
Stop tracking for a post |
UpdateCount |
Server → Client | int count |
Receive updated reader count |
The dashboard provides:
- Total post count
- 5 most recently updated posts
- Quick links to all management areas
- Enter a Title (required)
- Enter a Summary (shown in listings)
- Write Content in Markdown
- Check Published to make visible
- Click Save
The slug is automatically generated from the title. If a collision occurs, -1, -2, etc. is appended.
Same as creating, with the current content pre-filled. The slug is regenerated if the title changes.
The editor shows a live preview of the rendered Markdown on the right side.
| Field | Requirements |
|---|---|
| Username | Unique, required |
| Display Name | Required, shown on posts |
| Required | |
| Password | Minimum 8 characters |
All fields except password can be updated. Leave password blank to keep current password.
Users can be deleted except for the currently logged-in user.
- Click the file input or drag-and-drop
- Select an image (JPEG, PNG, GIF, or WebP)
- Image uploads automatically
Copy the URL shown below each image thumbnail and paste into your Markdown:
Click Delete below any image. This action is immediate and permanent.
- Enter Current Password
- Enter New Password (minimum 8 characters)
- Confirm New Password
- Click Change Password
The MYBLOG_ADMIN_PASSWORD environment variable does NOT override existing passwords.
MyBlog.Tests/
├── Integration/
│ ├── AuthServiceLongPasswordTests.cs
│ ├── AuthServiceTests.cs
│ ├── PasswordChangeTests.cs
│ ├── PostRepositoryTests.cs
│ └── TelemetryCleanupTests.cs
└── Unit/
├── LoginRateLimitMiddlewareTests.cs
├── MarkdownServiceTests.cs
├── PasswordServiceTests.cs
└── SlugServiceTests.cs
Test individual components in isolation with mock dependencies.
PasswordServiceTests (5 tests)
| Test | Purpose |
|---|---|
HashPassword_ReturnsNonEmptyHash |
Verify hashing produces output |
HashPassword_ReturnsDifferentHashForSamePassword |
Verify salt randomization |
VerifyPassword_WithCorrectPassword_ReturnsTrue |
Verify correct password matches |
VerifyPassword_WithWrongPassword_ReturnsFalse |
Verify incorrect password fails |
VerifyPassword_WithEmptyPassword_ReturnsFalse |
Verify empty password fails |
SlugServiceTests (7 tests)
| Test | Purpose |
|---|---|
GenerateSlug_WithSimpleTitle_ReturnsLowercaseWithHyphens |
Basic transformation |
GenerateSlug_WithSpecialCharacters_RemovesThem |
Punctuation removal |
GenerateSlug_WithMultipleSpaces_CollapsesToSingleHyphen |
Space normalization |
GenerateSlug_WithUnicode_RemovesDiacritics |
Unicode handling |
GenerateSlug_WithLeadingTrailingSpaces_TrimsHyphens |
Edge trimming |
GenerateSlug_WithNumbers_PreservesNumbers |
Number preservation |
GenerateSlug_WithEmptyStringOrWhitespace_ReturnsGuidWithPrefix |
Fallback behavior |
MarkdownServiceTests (18 tests)
| Test | Purpose |
|---|---|
ToHtml_WithHeading1_ReturnsH1Tag |
H1 parsing |
ToHtml_WithHeading2_ReturnsH2Tag |
H2 parsing |
ToHtml_WithHeading6_ReturnsH6Tag |
H6 parsing |
ToHtml_WithBoldText_ReturnsStrongTag |
Bold parsing |
ToHtml_WithItalicText_ReturnsEmTag |
Italic parsing |
ToHtml_WithLink_ReturnsAnchorTag |
Link parsing |
ToHtml_WithImage_InjectsDimensions_IfResolvable |
Image with dimensions |
ToHtml_WithImage_NoDimensions_IfUnresolvable |
Image without dimensions |
ToHtml_WithImage_WhenServiceThrows_StillRendersImage |
Error handling |
ToHtml_WithInlineCode_ReturnsCodeTag |
Inline code |
ToHtml_WithCodeBlock_ReturnsPreCodeTags |
Code blocks |
ToHtml_WithBlockquote_ReturnsBlockquoteTag |
Blockquotes |
ToHtml_WithUnorderedList_ReturnsUlLiTags |
Unordered lists |
ToHtml_WithOrderedList_ReturnsOlLiTags |
Ordered lists |
ToHtml_WithHorizontalRule_ReturnsHrTag |
Horizontal rules |
ToHtml_WithEmptyString_ReturnsEmpty |
Empty input |
ToHtml_WithNull_ReturnsEmpty |
Null input |
ToHtml_WithMultipleImages_ProcessesAll |
Multiple images |
LoginRateLimitMiddlewareTests (8 tests)
| Test | Purpose |
|---|---|
InvokeAsync_NonLoginRequest_PassesThroughImmediately |
Non-login requests unaffected |
InvokeAsync_GetLoginRequest_PassesThroughImmediately |
GET requests unaffected |
InvokeAsync_FirstFiveAttempts_NoDelay |
Grace period |
InvokeAsync_SixthAttempt_HasOneSecondDelay |
First delay |
InvokeAsync_ProgressiveDelays_IncreaseExponentially |
Exponential backoff |
InvokeAsync_DelayCappedAt30Seconds |
Maximum delay cap |
InvokeAsync_AfterManyAttempts_NeverBlocks |
Never blocks (100 attempts) |
InvokeAsync_DifferentIPs_IndependentTracking |
Per-IP isolation |
Test components with real database (in-memory SQLite).
AuthServiceTests (5 tests)
| Test | Purpose |
|---|---|
AuthenticateAsync_WithValidCredentials_ReturnsUser |
Successful login |
AuthenticateAsync_WithInvalidPassword_ReturnsNull |
Wrong password |
AuthenticateAsync_WithNonExistentUser_ReturnsNull |
Unknown user |
EnsureAdminUserAsync_WhenNoUsersExist_CreatesAdmin |
Initial setup |
EnsureAdminUserAsync_WhenUsersExist_DoesNotCreateAnother |
Idempotence |
AuthServiceLongPasswordTests (8 tests)
| Test | Purpose |
|---|---|
AuthenticateAsync_With128CharacterPassword_Succeeds |
Long password support |
AuthenticateAsync_With256CharacterPassword_Succeeds |
Very long password |
AuthenticateAsync_With512CharacterPassword_Succeeds |
Extra long password |
ChangePasswordAsync_With128CharacterNewPassword_Succeeds |
Long password change |
AuthenticateAsync_WithComplexLongPassword_Succeeds |
Mixed character password |
AuthenticateAsync_After100FailedAttempts_StillAllowsLogin |
No lockout (100) |
AuthenticateAsync_After1000FailedAttempts_StillAllowsLogin |
No lockout (1000) |
AuthenticateAsync_InterleavedFailuresAndSuccesses_NeverLocks |
Interleaved attempts |
PasswordChangeTests (7 tests)
| Test | Purpose |
|---|---|
ChangePasswordAsync_WithCorrectCurrentPassword_ReturnsTrue |
Successful change |
ChangePasswordAsync_WithCorrectPassword_AllowsLoginWithNewPassword |
New password works |
ChangePasswordAsync_WithWrongCurrentPassword_ReturnsFalse |
Wrong current password |
ChangePasswordAsync_WithWrongPassword_DoesNotChangePassword |
Failed change preserves old |
ChangePasswordAsync_WithNonExistentUser_ReturnsFalse |
Invalid user |
ResetPasswordAsync_SetsNewPassword |
Admin reset |
ResetPasswordAsync_WithNonExistentUser_ThrowsException |
Invalid user throws |
PostRepositoryTests (8 tests)
| Test | Purpose |
|---|---|
CreateAsync_AddsPostToDatabase |
Create post |
GetByIdAsync_WithExistingId_ReturnsPost |
Retrieve by ID |
GetByIdAsync_WithNonExistingId_ReturnsNull |
Non-existent ID |
GetBySlugAsync_WithExistingSlug_ReturnsPost |
Retrieve by slug |
GetPublishedPostsAsync_ReturnsOnlyPublishedPosts |
Published filter |
UpdateAsync_ModifiesPost |
Update post |
DeleteAsync_RemovesPost |
Delete post |
GetPublishedPostsAsync_ReturnsCorrectCount |
Pagination count |
TelemetryCleanupTests (3 tests)
| Test | Purpose |
|---|---|
DeleteOlderThanAsync_RemovesOldLogs |
Cleanup old logs |
DeleteOlderThanAsync_WithNoOldLogs_ReturnsZero |
No-op when nothing to clean |
DeleteOlderThanAsync_WithEmptyTable_ReturnsZero |
Empty table handling |
# Run all tests
cd src
dotnet test MyBlog.slnx
# Run with detailed output
dotnet test MyBlog.slnx --verbosity normal
# Run specific test class
dotnet test MyBlog.slnx --filter "FullyQualifiedName~SlugServiceTests"
# Run specific test
dotnet test MyBlog.slnx --filter "FullyQualifiedName~GenerateSlug_WithUnicode"
# Generate TRX report
dotnet test MyBlog.slnx --logger trx --results-directory TestResultsIntegration tests use in-memory SQLite:
var options = new DbContextOptionsBuilder<BlogDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
_context = new BlogDbContext(options);
_context.Database.OpenConnection(); // Keep connection open
_context.Database.EnsureCreated();Benefits:
- Fast execution (no disk I/O)
- Isolated per test class
- Cross-platform compatible
- No cleanup required
The rate limiting middleware uses an injectable delay function for testing:
// Production: Real delays
public LoginRateLimitMiddleware(RequestDelegate next, ILogger logger)
: this(next, logger, null) { }
// Testing: No-op delay function
public LoginRateLimitMiddleware(
RequestDelegate next,
ILogger logger,
Func<TimeSpan, CancellationToken, Task>? delayFunc)
{
_delayFunc = delayFunc;
}
// In InvokeAsync:
if (_delayFunc != null)
await _delayFunc(delay, context.RequestAborted); // Test path
else
await Task.Delay(delay, context.RequestAborted); // Production pathThis allows tests to run instantly while still verifying delay calculations.
The project includes a complete CI/CD pipeline (.github/workflows/build-deploy.yml):
name: Build, Test, and Deploy
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
build-test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore src/MyBlog.slnx
- name: Build solution
run: dotnet build src/MyBlog.slnx -c Release --no-restore
- name: Run tests
run: dotnet run --project src/MyBlog.Tests/MyBlog.Tests.csproj
deploy:
needs: build-test
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop'
runs-on: windows-latest
# ... deployment steps| Secret | Description | Example |
|---|---|---|
WEBSITE_NAME |
IIS site name | MyBlog |
SERVER_COMPUTER_NAME |
Server hostname | myserver.example.com |
SERVER_USERNAME |
WebDeploy username | deploy-user |
SERVER_PASSWORD |
WebDeploy password | (secure) |
The deployment uses WebDeploy with important features:
& $msdeployPath -verb:sync $sourceArg $destArg `
-allowUntrusted `
-enableRule:DoNotDeleteRule ` # Preserve existing files
-enableRule:AppOffline ` # Graceful shutdown
-retryAttempts:3 `
-retryInterval:3000AppOffline Rule: Creates app_offline.htm to:
- Stop the application gracefully
- Release file locks
- Allow deployment
- Remove the file to restart
# Build for Windows
dotnet publish src/MyBlog.Web/MyBlog.Web.csproj -c Release -o ./publish -r win-x64
# Copy to server
xcopy /s /y publish \\server\wwwroot\MyBlog
# Or use WebDeploy
msdeploy -verb:sync -source:contentPath=./publish -dest:contentPath=MyBlog,...# Build for Linux
dotnet publish src/MyBlog.Web/MyBlog.Web.csproj -c Release -o ./publish -r linux-x64
# Copy to server
rsync -avz publish/ user@server:/var/www/myblog/
# Create systemd service
sudo nano /etc/systemd/system/myblog.serviceExample systemd service:
[Unit]
Description=MyBlog Web Application
After=network.target
[Service]
WorkingDirectory=/var/www/myblog
ExecStart=/var/www/myblog/MyBlog.Web
Restart=always
RestartSec=10
KillSignal=SIGINT
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
[Install]
WantedBy=multi-user.target- Install .NET 10 Hosting Bundle
- Create a new IIS site pointing to the publish folder
- Set Application Pool to "No Managed Code"
- Ensure the Application Pool identity has write access to:
%LOCALAPPDATA%\MyBlog\(database)- Telemetry directory (if file logging enabled)
Cause: Application DLLs are locked because the app is running.
Solution: Use the -enableRule:AppOffline flag in WebDeploy (included in the GitHub Actions workflow).
Cause: The environment variable only works when no users exist in the database.
Solution:
- Stop the application
- Delete the database file
- Set
MYBLOG_ADMIN_PASSWORD - Start the application
Or use /admin/change-password to change the password through the UI.
Cause: SQLite can have locking issues with concurrent access.
Solutions:
- Ensure only one instance of the application is running
- Check that no database tools have the file open
- Verify file permissions on the database directory
Cause: localStorage might be blocked or cleared.
Solutions:
- Check browser privacy settings
- Ensure JavaScript is enabled
- Clear browser cache and try again
Cause: Image URL might be incorrect or image was deleted.
Solutions:
- Verify the GUID in the URL matches an existing image
- Check
/admin/imagesto confirm the image exists - Ensure the URL format is
/api/images/{guid}
Note: Rate limiting never blocks, only delays.
Expected delays after failed login attempts:
- Attempts 1-5: No delay
- Attempt 6: 1 second
- Attempt 7: 2 seconds
- etc., up to 30 seconds maximum
Solution: Wait for the delay window (15 minutes) to reset, or use the correct password.
Symptoms: Reader count not updating.
Solutions:
- Check browser console for WebSocket errors
- Verify
/readerHubendpoint is accessible - Check for proxy/firewall blocking WebSocket connections
- Clone the repository
- Install .NET 10 SDK
- Open
src/MyBlog.slnxin your IDE - Run
dotnet restore - Run
dotnet build - Run
dotnet test
The project uses .editorconfig for consistent formatting:
- File-scoped namespaces
- 4-space indentation
- LF line endings
- Private fields prefixed with
_
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Write tests for new functionality
- Ensure all tests pass
- Commit changes (
git commit -m 'Add amazing feature') - Push to branch (
git push origin feature/amazing-feature) - Open a Pull Request
- All new features must have corresponding tests
- All tests must pass on Windows, Linux, and macOS
- Code coverage should not decrease
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- Built with .NET and Blazor
- Observability powered by OpenTelemetry
- Inspired by the simplicity of static site generators with the power of dynamic applications
- AI assistance provided by Claude (Anthropic) and Gemini (Google) among others
Built with ❤️ using .NET 10 and Blazor Server