diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d0bbb2d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+node_modules
+dist
+.git
+.github
+.vscode
+.wrangler
+.env
+.env.*
+server.log
+coverage
+npm-debug.log*
diff --git a/.env.example b/.env.example
index d6f49b8..d41495f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,54 +1,48 @@
GITHUB_TOKEN=your_github_personal_access_token_here
PORT=3102
-APP_ENV=development
-
+APP_ENV=production
WARMUP_USERNAME=pphatdev
-# Redis Cache Configuration (optional)
-# If not set, defaults to redis://localhost:6379
-# Redis is optional - the app will work with in-memory caching if Redis is unavailable
-
-# ============================================================================
-# Disable Redis (use in-memory cache only)
-# ============================================================================
-# If you're experiencing connection issues (e.g., TLS errors), you can disable Redis:
-# REDIS_ENABLED=false
-
-# ============================================================================
-# Option 1: URL-based configuration (simple)
-# ============================================================================
-
-# For local development:
-# REDIS_URL=redis://localhost:6379
-
-# For Redis with authentication:
-# REDIS_URL=redis://username:password@localhost:6379
-
-# For remote Redis:
-# REDIS_URL=redis://your-redis-host.com:6379
-
-# ============================================================================
-# Option 2: Socket-based configuration (for Redis Cloud, AWS ElastiCache, etc.)
-# ============================================================================
-
-# Example: Redis Cloud
+# Per-instance secret used to salt client IPs before hashing for the visitor
+# dedup counter. Generate with `openssl rand -hex 32`. Must be 16+ chars.
+# Missing in dev triggers a boot warning; missing in prod means IP hashes
+# use a well-known dev fallback (insecure).
+SERVER_SALT=
+
+# Database provider selection
+# cloudflare: use Cloudflare D1 over HTTP
+# sqlite: use local SQLite file
+DATABASE_PROVIDER=sqlite
+
+# Local SQLite file path (used when DATABASE_PROVIDER=sqlite)
+DATABASE_URL=./data/stats.db
+# Cloudflare / Wrangler / D1
+# Used by drizzle.config.ts when running Drizzle commands against D1.
+# Required when DATABASE_PROVIDER=cloudflare.
+# Not required for local SQLite.
+CLOUDFLARE_ACCOUNT_ID=
+CLOUDFLARE_D1_DATABASE_ID=
+CLOUDFLARE_D1_TOKEN=
+
+# Tunnel
+CLOUDFLARED_TUNNEL_NAME=github-stats
+CLOUDFLARED_TUNNEL_TOKEN=
+
+# Local Docker Compose Redis (default)
+REDIS_HOST=redis
+REDIS_PORT=6379
+REDIS_USERNAME=
+REDIS_PASSWORD=
+REDIS_TLS=false
+REDIS_DB=0
+
+# Example: Redis Cloud (optional)
# REDIS_HOST=redis-10434.crce262.us-east-1-1.ec2.cloud.redislabs.com
# REDIS_PORT=10434
# REDIS_USERNAME=default
# REDIS_PASSWORD=your_password_here
# REDIS_TLS=true
-# Example: AWS ElastiCache
-# REDIS_HOST=my-cache.abc123.ng.0001.use1.cache.amazonaws.com
-# REDIS_PORT=6379
-# REDIS_USERNAME=default
-# REDIS_PASSWORD=your_password_here
-# REDIS_TLS=true
-
-# Note: TLS is automatically detected for known cloud providers
-# Auto-detection works for: cloud.redislabs.com, cache.amazonaws.com, render.com, etc.
-# To override auto-detection: explicitly set REDIS_TLS=true or REDIS_TLS=false
-
# ============================================================================
# Debugging
# ============================================================================
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..487088c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,40 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..bbcbbe7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..5571107
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,38 @@
+## Description
+
+
+
+## Type of Change
+
+
+
+- [ ] ๐ Bug fix (non-breaking change that fixes an issue)
+- [ ] โจ New feature (non-breaking change that adds functionality)
+- [ ] ๐ฅ Breaking change (fix or feature that would cause existing functionality to change)
+- [ ] ๐จ Style / UI update
+- [ ] โป๏ธ Refactor (no functional changes)
+- [ ] ๐ Documentation update
+- [ ] โก Performance improvement
+- [ ] ๐งช Tests
+
+## Related Issues
+
+
+
+## Changes Made
+
+
+
+-
+
+## Screenshots
+
+
+
+## Checklist
+
+- [ ] My code follows the existing code style of this project
+- [ ] I have tested my changes locally
+- [ ] I have added/updated tests as needed
+- [ ] My changes generate no new warnings or errors
+- [ ] I have updated documentation as needed
diff --git a/.gitignore b/.gitignore
index 626a7ad..3e4d1e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,14 @@ public/user/
# OS
Thumbs.db
+
+# Cloudflare
+.wrangler/
+.dev.vars
+
+# Bun
+.bun/
+.env*.local
+
+
+.claude
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b77ec25..e0d9c85 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,6 +2,12 @@
Thank you for your interest in contributing to GitHub Stats! ๐
+## Getting Started
+
+Before contributing, please review:
+- [Project Structure](docs/PROJECT_STRUCTURE.md) - Understand the codebase organization
+- [Development Guide](docs/how-to/DEVELOPMENT.md) - Setup and development workflow
+
## How to Contribute
### Reporting Bugs
@@ -35,7 +41,7 @@ We welcome feature suggestions! Please create an issue with:
To add a new theme:
-1. Edit the appropriate file in `src/utils/themes/`:
+1. Edit the appropriate file in `src/shared/utils/themes/`:
- `base.ts` - General-purpose themes (stats, languages)
- `graph.ts` - Graph/heatmap optimized themes
- `badge.ts` - Badge-specific themes
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..21bd7e8
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,27 @@
+FROM node:20-bookworm-slim AS deps
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+
+FROM deps AS build
+WORKDIR /app
+COPY . .
+RUN npm ci && npm run build && npm prune --omit=dev
+
+FROM node:20-bookworm-slim AS runtime
+ENV NODE_ENV=production
+ENV WORKERS=0
+WORKDIR /app
+
+# Copy as root so we can chown to the non-root `node` user shipped with the
+# official Node image (uid 1000). Subsequent process runs as `node` so a
+# code-execution bug can't touch /usr, /etc, or write outside /app (L4).
+COPY --from=build --chown=node:node /app/package*.json ./
+COPY --from=build --chown=node:node /app/node_modules ./node_modules
+COPY --from=build --chown=node:node /app/dist ./dist
+COPY --from=build --chown=node:node /app/public ./public
+
+USER node
+
+EXPOSE 3000
+CMD ["node", "dist/server-cluster.js"]
diff --git a/README.md b/README.md
index 55cf188..a8ce8b3 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,29 @@
-
+
-
-
-
-
-
-
+
+
# Fast GitHub Stats Graph ๐
Create beautiful GitHub stats cards, badges, icons, and contribution graphs that are easy to customize and perfect for your profile README or project docs.
+Docker build guide: [docs/docker-build.md](docs/docker-build.md)
+
+## ๐ For Load balance
+
+|Domain|Owner|Notes|
+|---|---|---|
+|https://stats.pphat.top|[@pphatdev](https://github.com/pphatdev)|Primary|
+|https://stats.sophat.top|[@pphatdev](https://github.com/pphatdev)|Secondary|
+|https://stats1.pphat.top|[@L-Sophat](https://github.com/L-Sophat)|Secondary Up time only: `08:00AM -> 05:00PM` Phnom Penh|
+|https://github-stats-3hu8.onrender.com|[@pphatdev](https://github.com/pphatdev)|Secondary|
+|https://githubstats.up.railway.app|[@L-Sophat](https://github.com/L-Sophat)|Secondary|
+
# ๐ Examples Icons Usage
@@ -212,88 +220,111 @@ for more detail checkout [Here](docs/example/graph.md)
# ๐ท๏ธ Badge Examples
-for more detail checkout [Here](docs/example/badge-user.md)
+Generate customizable GitHub badges for users and repositories with real-time data, caching, and visual effects.
+
+For more details: [User Badges](docs/example/badge-user.md) ยท [Badge Collections](docs/example/badge-collection.md) ยท [Project Badges](docs/example/project.md) ยท [Full Spec](docs/features/badges.md)
-### Popular Badge Types
+### Route
```
-
-
-
-
+/badges?username={username}&repo={repo}&name={badge1,badge2,...}&theme={theme}&effect={wave|glow}&column={1-50}&size={small|medium|large}&p={0-100}
```
-
-
-
-
+### Parameters
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `username` | *(required)* | GitHub username |
+| `repo` | โ | Repository name (e.g., `owner/repo`) |
+| `name` | โ | Comma-separated badge names |
+| `theme` | `default` | Comma-separated theme(s) (e.g., `ocean`, `galaxy`, `aurora`) |
+| `effect` | โ | Animation effect: `wave` or `glow` |
+| `column` | `50` | Number of columns (1โ50) |
+| `size` | `small` | Badge size: `small`, `medium`, or `large` |
+| `p` | `0` | Container padding in pixels (0โ100) |
+| `realtime` | `false` | Bypass cache for fresh data (30s cooldown) |
+
+### Supported Badge Types
+
+**User Badges:** `visitors` ยท `repositories` ยท `followers` ยท `organization` ยท `languages` ยท `total-stars` ยท `total-contributors` ยท `total-commits` ยท `total-code-reviews` ยท `total-issues` ยท `total-pull-requests` ยท `total-joined-years`
+
+**Repository Badges** (requires `repo`): `stars` ยท `forks` ยท `contributors` ยท `issues` ยท `pull-requests` ยท `watchers` ยท `size`
-### Theme + Custom Label
+### Single User Badge
```
-
+
```
-
+
+
+
+
-### Color and Layout Controls
+### Multiple Badges with Theme
```
-
-
+
```
-
-
+
+### Multiple Themes (cycled per badge)
-# ๐ Project Badge Examples
+```
+
+```
-for more detail checkout [Here](docs/example/project.md)
+
-### Popular Project Badge Types
+### Repository Badges
```
-
-
-
-
+
```
-
-
-
-
+
-### Theme + Custom Label
+### Effects (`glow` | `wave`)
```
-
+
+
```
-
+
+
+
+### Combined Example (layout + themes + effect + padding)
+
+```
+
+```
-### Visitors Rule (Same IP)
+
-Project visitors increment once per same IP every 5 minutes.
+### Fresh Data with Realtime
```
-
+
```
+
-## Development
-Development setup was moved to: [docs/how-to/DEVELOPMENT.md](docs/how-to/DEVELOPMENT.md)
+## Development
-Route-by-route demos with option examples: [docs/example/README.md](docs/example/README.md)
+**Documentation:**
+- [Development Setup Guide](docs/how-to/DEVELOPMENT.md) - Environment setup, database, and run commands
+- [Project Structure](docs/PROJECT_STRUCTURE.md) - Complete codebase architecture and organization
+- [Route Examples](docs/example/README.md) - Route-by-route demos with option examples
## Architecture
- **API**: GitHub REST + GraphQL APIs with intelligent batching
- **Caching**: Multi-tier (Memory โ Redis โ Source) with 2-hour default TTL
- **Database**: SQLite with Drizzle ORM for badge counters and visitor logs
-- **Server**: Express.js with optional cluster mode for multi-core scaling
+- **Server**: Express.js with round-robin cluster load balancing in production
- **Rendering**: Server-side SVG generation with optional WebP/PNG/GIF export
## Notes
@@ -302,7 +333,8 @@ Route-by-route demos with option examples: [docs/example/README.md](docs/example
- Without a GitHub token, API rate limits are very low (~60 requests/hour)
- Set `GITHUB_TOKEN` to get 5,000 requests/hour
- Redis is optional but recommended for production (enables distributed caching)
-- User visitor badges (`/badge/visitors`) use IP hashing for privacy-preserving unique visitor counting
+- Docker and `npm start` now boot the clustered entrypoint; set `WORKERS` to cap worker count, or leave it at `0` to use all available CPU cores
+- User visitor badges (`/badges?username=...&name=visitors`) use IP hashing for privacy-preserving unique visitor counting
- Project visitor badges (`/project/visitors`) increment once per same IP every 5 minutes
## License
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..27154d5
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,73 @@
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: ${CLOUDFLARED_TUNNEL_NAME}-app
+ restart: unless-stopped
+ ports:
+ - "3102:${PORT:-3000}"
+ environment:
+ NODE_ENV: production
+ APP_ENV: production
+ HOST: 0.0.0.0
+ PORT: ${PORT:-3000}
+ WORKERS: ${WORKERS:-0}
+ DATABASE_PROVIDER: ${DATABASE_PROVIDER:-sqlite}
+ DATABASE_URL: ${DATABASE_URL:-/app/data/stats.db}
+ CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID:-}
+ CLOUDFLARE_D1_DATABASE_ID: ${CLOUDFLARE_D1_DATABASE_ID:-}
+ CLOUDFLARE_D1_TOKEN: ${CLOUDFLARE_D1_TOKEN:-}
+ REDIS_ENABLED: "true"
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ GITHUB_TOKEN: ${GITHUB_TOKEN:-}
+ volumes:
+ - app-data:/app/data
+ depends_on:
+ redis:
+ condition: service_healthy
+ healthcheck:
+ test:
+ - CMD
+ - node
+ - -e
+ - fetch(`http://127.0.0.1:${PORT:-3000}/health`).then((response)=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 20s
+ stop_grace_period: 20s
+
+ redis:
+ image: redis:7-alpine
+ container_name: ${CLOUDFLARED_TUNNEL_NAME}-redis
+ restart: unless-stopped
+ command: ["redis-server", "--appendonly", "yes"]
+ volumes:
+ - redis-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ cloudflared:
+ image: cloudflare/cloudflared:latest
+ container_name: ${CLOUDFLARED_TUNNEL_NAME}-cloudflared
+ restart: unless-stopped
+ network_mode: "service:app"
+ depends_on:
+ app:
+ condition: service_healthy
+ environment:
+ TUNNEL_TOKEN: ${CLOUDFLARED_TUNNEL_TOKEN:?Set CLOUDFLARED_TUNNEL_TOKEN in .env}
+ command:
+ - tunnel
+ - --no-autoupdate
+ - run
+ - ${CLOUDFLARED_TUNNEL_NAME}
+
+volumes:
+ app-data:
+ redis-data:
\ No newline at end of file
diff --git a/docs/how-to/RELEASE_ICONS.md b/docs/RELEASE/RELEASE_ICONS.md
similarity index 88%
rename from docs/how-to/RELEASE_ICONS.md
rename to docs/RELEASE/RELEASE_ICONS.md
index 0815aa1..e4cf6aa 100644
--- a/docs/how-to/RELEASE_ICONS.md
+++ b/docs/RELEASE/RELEASE_ICONS.md
@@ -20,6 +20,7 @@ Icons enhance release documentation by:
- [Technology Stack Display](#technology-stack-display)
- [Themed Icon Sets](#themed-icon-sets)
- [Best Practices](#best-practices)
+- [Current Release (v2.0.1)](#current-release-v201)
---
@@ -158,7 +159,6 @@ Create a professional technology stack section:
### DevOps


-
```
### Compact Icon Grid
@@ -297,6 +297,51 @@ Icons are SVGs served directly from the server. They're lightweight and render q
---
+## Current Release (v2.0.1)
+
+Use this section as a copy-ready template for the current release.
+
+### Highlights
+
+- Added `GET /badge/collection` to render multiple user badges into one SVG.
+- Added collection layout controls: `columns` and `gap`.
+- Added endpoint examples and documentation for badge collection in `docs/example/badge-collection.md`.
+- Fixed collection SVG composition to avoid duplicate `width`/`height` attributes.
+
+### Release Notes Template
+
+```markdown
+# Release v2.0.1
+
+  
+
+## Added
+- `GET /badge/collection` endpoint for multi-badge SVG output.
+- New docs: `docs/example/badge-collection.md`.
+
+## Improved
+- Better layout customization for badge collections using `columns` and `gap`.
+
+## Fixed
+- XML parsing issue in composed badge SVGs caused by duplicate attributes.
+
+## Quick Demo
+
+```
+
+### Release Badge Examples (v2.0.1)
+
+```markdown
+
+
+```
+
+Preview:
+
+
+
+---
+
## Advanced Examples
### Changelog with Icons
diff --git a/docs/RELEASE/RELEASE_v2.0.3.md b/docs/RELEASE/RELEASE_v2.0.3.md
new file mode 100644
index 0000000..0c73889
--- /dev/null
+++ b/docs/RELEASE/RELEASE_v2.0.3.md
@@ -0,0 +1,76 @@
+# Release v2.0.3
+
+Release date: 2026-04-04
+
+## Summary ๐
+
+This release focuses on consistency, maintainability, and documentation improvements.
+
+## Highlights โจ
+
+- ๐งฉ Added badge collection examples to the main README.
+- ๐จ Added support for multiple themes in badge collections (theme cycling by badge index).
+- โ
Added explicit theme validation for badge collection requests.
+- ๐งฑ Standardized controller filenames to the pattern `filename.controller.ts`.
+- ๐ ๏ธ Standardized service filenames to the pattern `filename.service.ts`.
+- ๐ Updated project version to `2.0.3`.
+
+## API Notes ๐
+
+### Badge Collection ๐งฉ
+
+Endpoint:
+
+- `GET /badge/collection`
+
+Key query parameters:
+
+- `username` (required)
+- `type` (required, comma-separated badge types)
+- `columns` (optional, 1-50)
+- `gap` (optional, 0-100)
+- `theme` (optional)
+
+Theme behavior:
+
+- Single theme: applies to all badges
+- Multiple themes: comma-separated list, applied in a loop across badges
+
+Example:
+
+```text
+https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues,followers&theme=galaxy,aurora,ocean
+```
+
+## Refactor Details ๐ง
+
+### Controllers renamed ๐ฎ
+
+- `stats.ts` -> `stats.controller.ts`
+- `languages.ts` -> `languages.controller.ts`
+- `graph.ts` -> `graph.controller.ts`
+- `badge.ts` -> `badge.controller.ts`
+- `controller.ts` -> `index.controller.ts`
+
+### Services renamed ๐งฐ
+
+- `base.ts` -> `base.service.ts`
+- `github-graphql-optimizer.ts` -> `github-graphql-optimizer.service.ts`
+
+## Compatibility ๐
+
+- No endpoint removals in this release.
+- Existing route behavior remains unchanged outside the documented badge collection improvements.
+
+## Verification Checklist โ
+
+- Build passes after import path updates.
+- `README.md` includes badge collection examples.
+- `package.json` version is `2.0.3`.
+- Controller and service imports use standardized file patterns.
+
+## Related Docs ๐
+
+- `README.md`
+- `docs/example/badge-collection.md`
+- `docs/RELEASE/RELEASE_ICONS.md`
diff --git a/docs/SECURITY_TODO.md b/docs/SECURITY_TODO.md
new file mode 100644
index 0000000..9eeff94
--- /dev/null
+++ b/docs/SECURITY_TODO.md
@@ -0,0 +1,156 @@
+# Security TODO
+
+Actionable follow-ups from the security audit (2026-08-24). Each item is scoped as a GitHub-issue-ready task with file references. Tackle top-to-bottom โ order reflects both severity and dependency (fix H1/H5 first because it unlocks several others).
+
+Legend: `[C]` Critical ยท `[H]` High ยท `[M]` Medium ยท `[L]` Low ยท `[I]` Info
+
+**Last refresh: 2026-09-19 (all-clear).** Every audit item is now closed โ see the "Done:" note under each for what shipped. Follow-ups worth watching (not audit findings):
+- Helmet CSP stays off; revisit when HTML routes appear.
+- `stats.service.pngCache` is still an unbounded `Map` (H6 handled the shared LRU only).
+- `strictRateLimiter` is only on `/stats` and `/badges`; `/graph` and `/languages` also fan out to GitHub and could benefit.
+- `/public/โฆ` static alias was dropped (I1). External consumers must use root-mounted paths; restore the alias temporarily if that breaks callers.
+
+---
+
+## Critical
+
+- [x] **[C1] Escape user input in SVG badge output (reflected XSS)** โ done 2026-09-19
+ - Files: `src/shared/components/badge-renderer.ts` (lines 231, 300-301); input flow `src/modules/badges/badges.controller.ts:215-239`
+ - Add `svgEscape(v)` helper (`& < > " '` โ entities). Apply to every `${...}` in SVG text nodes and attribute values.
+ - Add `hexColorOrReject(v)` (`/^#[0-9a-fA-F]{3,8}$/`) at the controller; reject on failure with 400.
+ - Repro: `GET /badges?username=x&name=visitors&customLabel=`
+ - **Done:** helpers landed in `src/shared/utils/svg-safe.ts` (`svgEscape` + `normalizeHexColor` โ accepts `#?[0-9a-fA-F]{3,4,6,8}` and returns canonical `#โฆ`). `labelText` in badge-renderer is now escaped after uppercasing; width is measured on raw glyphs. Controller-level hex validation was folded into the Zod schemas in H1, so bad colors are rejected at the route boundary.
+
+---
+
+## High
+
+- [x] **[H1] Wire up the existing Zod validation middleware** โ done 2026-09-19
+ - File: `src/shared/validations/validation.ts` (defined, unused) ยท middleware at `src/shared/middlewares/error.middleware.ts:163`
+ - Mount `validate(schema, 'query')` on every route in `stats.routes.ts`, `badges.routes.ts`, `graphs.routes.ts`, `languages.routes.ts`.
+ - Switch controllers to read from `req.validated` instead of `req.query`.
+ - Tighten `themeSchema` to `z.enum([...knownThemes])`. Add hex-color validation for badge color params.
+ - **Done:** schemas rewritten to match each endpoint's real query surface (statsQuerySchema/graphQuerySchema/badgeQuerySchema/languagesQuerySchema). `colorHex` now matches `normalizeHexColor` semantics and normalizes to `#โฆ`. `themeSchema` uses `.refine(isKnownTheme)` against a new registry helper in `themes.ts`; a separate `badgeThemeCsvSchema` per-item-validates the CSV theme param on `/badges`. `validate(...)` is mounted on all four routes. Controllers read `req.validated` and the ad-hoc color gates from C1/H2/H3 are gone. ZodError โ shared `errorHandler` โ JSON 400 with `error.details.fields`.
+
+- [x] **[H2] Escape/validate `custom_title` + colors in stats card (reflected XSS)** โ done 2026-09-19
+ - File: `src/shared/components/card-renderer.ts` (lines 146-148, 171, 175-178, 197-198, 207-210, 277, 297-303, 390)
+ - Same fix as C1 (`svgEscape` + `hexColorOrReject`).
+ - **Done:** `customTitle` (and its `stats.name` fallback) is passed through `svgEscape` before it hits the `` node. Color params flow through the Zod `colorHex` schema (H1) โ invalid colors 400 at the route.
+
+- [x] **[H3] Escape/validate colors in graph card (reflected XSS)** โ done 2026-09-19
+ - File: `src/shared/components/graph-renderer.ts` (lines 294, 315, 322, 325, 352, 361-364, 374-378, 381-385, 391-392)
+ - Same fix as C1.
+ - **Done:** `titleText` (composed from `data.username + " 's Activity " + data.year`) is escaped before render; width is measured on the raw string so `&<>"'` don't inflate padding. Color params validated by the shared Zod `colorHex`.
+
+- [x] **[H4] Implement real visitor dedup (stop counter inflation)** โ done 2026-09-19
+ - Files: `src/modules/badges/badges.service.ts:50-52, 356-376` ยท schema `src/db/schema.ts:20-38` (`visitor_logs` table already defined, never written)
+ - `INSERT OR IGNORE INTO visitor_logs (username, ip_hash, visit_date)` first; only bump `badges.visitors` when the insert succeeded (unique index didn't reject).
+ - `ip_hash = sha256(ip + SERVER_SALT)` โ add `SERVER_SALT` to env schema.
+ - Validate `username` against `USERNAME_PATTERN` from `src/modules/users/users.controller.ts:15` before write.
+ - Depends on L1 (`trust proxy`) for correct client IP.
+ - **Done:** new `src/shared/utils/visitor.ts` exposes `hashClientIp` (SHA-256 over `salt:ip`) and `currentVisitDateUtc`. `SERVER_SALT` added to env schema (optional, min 16 chars) with a boot warning and a marker dev fallback. `getVisitorCount` now runs `INSERT ... ON CONFLICT DO NOTHING` against `visitor_logs`; a suppressed insert returns the current total without touching `badges.visitors`. Malformed usernames are rejected up front against the shared `GITHUB_USERNAME_RE` from `src/shared/utils/username.ts` (this is the H4 use of USERNAME_PATTERN + closes I2 for badges/tracker; `users.controller.ts:15` still has its local copy). Missing `req.ip` also refuses the bump (belt-and-braces after L1).
+
+- [x] **[H5] Mount helmet + rate limiter (they exist, aren't wired)** โ done 2026-09-19
+ - Files: `src/shared/middlewares/performance.middleware.ts:38-66` (defined) ยท `src/app.ts:37` (missing usage)
+ - Add:
+ ```ts
+ import { rateLimiter, securityMiddleware } from './shared/middlewares/performance.middleware.js';
+ app.use(securityMiddleware);
+ app.use(rateLimiter);
+ ```
+ - Add stricter `strictRateLimiter` on `/badges` and `/stats` (each request may hit GitHub API).
+ - Configure helmet CSP; allow SVG endpoints to override.
+ - **Done:** `app.set('trust proxy', 1)` set before any per-IP middleware (also closes L1). The manual 3-header block was replaced by `securityMiddleware` (helmet with `crossOriginResourcePolicy: 'cross-origin'` so badges still embed in GitHub READMEs; CSP stays off because there's no HTML surface). Global `rateLimiter` mounted on the app; `strictRateLimiter` layered on `/stats` and `/badges`. CSP tightening deferred โ not needed for API/SVG surface but should revisit if HTML routes appear. `/graph` and `/languages` also hit GitHub and could benefit from `strictRateLimiter`; leaving as a follow-up.
+
+- [x] **[H6] Bound the in-memory cache (memory DoS)** โ done 2026-09-19
+ - Files: `src/server.ts:22` (shared `Map`) ยท key at `src/modules/badges/badges.service.ts:389-397`
+ - Replace `Map` with `lru-cache` (cap ~10k, TTL matching current `cacheDuration`).
+ - Drop user-controlled `options` from cache key โ hash a normalized subset only. Mirror `src/modules/icons/icons.service.ts:395-410`.
+ - **Done:** installed `lru-cache@11`. New factory `src/shared/utils/response-cache.ts` returns an LRU capped at 10k with TTL = `env.CACHE_DURATION`. All four service constructors and route factories now take a structural `ResponseCache` (both `LRUCache` and plain `Map` satisfy it โ Worker entrypoint keeps its Map unchanged). Badge cache key is now a fixed-shape array of only render-affecting options (theme, customLabel, customType, five color fields, hideFrame, padding); `realtime` is deliberately excluded so freshness-mode toggling can't churn cache entries. Stats' `pngCache` still uses an unbounded `Map` โ flag for follow-up.
+
+- [x] **[H7] Bound `stats_requests` growth** โ done 2026-09-19
+ - File: `src/shared/middlewares/track-request.middleware.ts:36-61`
+ - Validate `username` against `USERNAME_PATTERN` before insert; reject bad values (don't 500).
+ - Add periodic cleanup job (prune rows older than N days).
+ - Consider hourly-bucket dedup via `INSERT OR IGNORE` on `(username, url, ua, hour_bucket)`.
+ - **Done:** shared `isValidGithubUsername` gates every insert. In-memory hourly dedup implemented via an LRU-capped Set (20k entries, 90 min TTL) keyed on `username|url|ua|hour_bucket` โ kept in memory rather than adding a DB unique index so no migration was needed; the trade-off is that dedup doesn't survive restart. New `src/shared/utils/stats-cleanup.ts` runs an immediate prune on boot and a repeating `setInterval(โฆ).unref()` afterward; retention (default 30d) and cadence (default 6h) come from env. `stopServer` clears the timer.
+
+---
+
+## Medium
+
+- [x] **[M1] Normalize `theme` before it enters the cache key** โ done 2026-09-19
+ - Files: `src/shared/utils/themes.ts:34-40` (`resolveThemeName`) ยท `src/modules/badges/badges.service.ts:389-397` ยท `src/modules/graphs/graphs.service.ts:168-184`
+ - Call `resolveThemeName(rawTheme)` in the controller; use the normalized value in both render and cache key. Or reject unknown themes with 400.
+ - **Done:** exported `normalizeThemeName` and `normalizeBadgeThemeName` from `themes.ts`. Zod `themeSchema` now `.transform`s to the canonical key so `/stats`, `/graph`, `/languages` see the normalized string in `req.validated.theme` and their cache keys. Badges controller normalizes each CSV entry via `normalizeBadgeThemeName` before storing, so the H6 cache-key fingerprint uses canonical values. `?theme=Ocean` and `?theme=ocean` collapse to one cache entry.
+
+- [x] **[M2] Tighten `COLOR_REGEX`** โ done 2026-09-19
+ - File: `src/modules/icons/icons.service.ts:25` ยท also `src/modules/icons/icons-collection.controller.ts:21-22`
+ - Replace `[a-zA-Z]+` branch with an explicit CSS-named-color allowlist.
+ - Replace `rgb\([^)]+\)` etc. with structured parsers: `^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(\s*,\s*(0|1|0?\.\d+))?\s*\)$`.
+ - **Done:** new `src/shared/utils/css-color.ts` with `isValidCssColor(v)` โ 148-entry CSS3 named-color allowlist plus structured hex/rgb/rgba/hsl/hsla parsers using the audit's regex shape. Both `icons.service` and `icons-collection.controller` deleted their local regex and now call the shared validator.
+
+- [x] **[M3] Stop leaking error internals to clients** โ done 2026-09-19
+ - Files: `src/modules/languages/languages.controller.ts:63` (returns `` `Error: ${error.message}` ``) ยท `src/shared/middlewares/error.middleware.ts:87-89` (`NODE_ENV !== 'production'` leaks) ยท `src/worker.ts:208-211` (always leaks)
+ - Return generic message to client; log full error server-side. Confirm `NODE_ENV=production` on all Node deploys.
+ - **Done:** all three sites return a generic string (`"Failed to generate language visualization"`, `"An unexpected error occurred"`, `"Internal Server Error"`). Full errors are logged server-side. `errorHandler` still puts a `requestId` in the JSON body so operators can correlate the client-facing response to a log line.
+
+- [x] **[M4] Fix CORS in non-production** โ done 2026-09-19
+ - File: `src/app.ts:55-61` (`origin:'*'` + `credentials:true`)
+ - In dev, drop `credentials: true` OR use an explicit dev-origin list (e.g. `http://localhost:*`).
+ - **Done:** the dev branch now sets `credentials: false` while keeping `origin: '*'`; prod continues to use the explicit origin allowlist with credentials.
+
+- [x] **[M5] Remove `rejectUnauthorized: false` from Redis TLS** โ done 2026-09-19
+ - File: `src/shared/utils/redis-client.ts:115-116, 128`
+ - Remove the flag. If the provider needs a custom CA, load it with `ca: fs.readFileSync(...)` and keep validation on.
+ - **Done:** flag deleted from both the Redis Cloud URL-config path and the socket-config path. SNI (`servername: host`) retained. If a managed provider ships a custom CA, load it via `ca: fs.readFileSync(...)` in the socket config โ validation now stays on.
+
+---
+
+## Low
+
+- [x] **[L1] `app.set('trust proxy', 1)`** โ done 2026-09-19 (as part of H5)
+ - File: `src/app.ts` (missing)
+ - Without this, `req.ip` is the CF/nginx IP โ per-IP rate limits (H5) and IP-hash dedup (H4) bucket everyone into one entry.
+ - Alternative: use `req.headers['cf-connecting-ip']` when Cloudflare is in front.
+ - **Done:** `app.set('trust proxy', 1)` in `createApp()` right before the header middleware stack.
+
+- [x] **[L2] Redact PII from debug logs** โ done 2026-09-19
+ - Files: `src/app.ts:72-86` ยท `src/shared/middlewares/error.middleware.ts:49-51, 134-141`
+ - Hash `req.ip`, drop raw `req.query` from prod logs, gate on `env.APP_ENV !== 'production'`.
+ - **Done:** `errorHandler` and `requestLogger` log `ipHash: hashClientIp(req.ip)` instead of the raw IP; `query: req.query` is only included when `APP_ENV !== 'production'`. `app.ts:72-86` never logged query params or IPs in the first place โ left as-is.
+
+- [x] **[L3] Drop `express.json` body limit** โ done 2026-09-19
+ - File: `src/app.ts:64-65`
+ - All routes are GET. Reduce to `100kb` or remove body parsers entirely.
+ - **Done:** limits reduced from `10mb` to `100kb` for both `express.json` and `express.urlencoded`. Body parsers kept for future POST endpoints; drop them entirely if the app stays GET-only.
+
+- [x] **[L4] Non-root container user** โ done 2026-09-19
+ - File: `Dockerfile` (lines 11-22)
+ - Add `USER node` (and `chown -R node:node /app` earlier).
+ - **Done:** each runtime-stage `COPY` now uses `--chown=node:node`, and `USER node` is set before `CMD`. The `node` user (uid 1000) ships with the official Node image so no groupadd/useradd steps are needed.
+
+- [x] **[L5] Distinguish ENOENT from other fs errors in icons controller** โ done 2026-09-19
+ - File: `src/modules/icons/icons.controller.ts:58-63`
+ - Return 500 for non-ENOENT errors instead of misleading 404.
+ - **Done:** the catch now branches: `Invalidโฆ` message โ 400, `err.code === 'ENOENT'` โ 404, everything else โ 500 with a generic body. Server-side log still captures the full error.
+
+---
+
+## Info / cleanup
+
+- [x] **[I1] Deduplicate static-file mounts** โ done 2026-09-19. The `/public` alias was removed from `app.ts`; root `/` is the single mount. Any external caller still hitting `/public/โฆ` will 404 and needs to update to root-relative paths.
+- [x] **[I2] Adopt the users/icons validation pattern project-wide** โ done 2026-09-19
+ - `src/modules/users/users.controller.ts:15` (`USERNAME_PATTERN`) and `src/modules/icons/icons.service.ts:337-347` (path-traversal check) are correct; reuse them in badges/stats/graphs/languages controllers.
+ - **Done:** canonical GitHub-username regex lives in `src/shared/utils/username.ts` and is used by `badges.service` (H4), `track-request.middleware` (H7), and `users.controller` (this cleanup). Stats/graphs/languages controllers validate username via the shared Zod `githubUsername` schema (H1). No local `USERNAME_PATTERN` copies remain.
+
+---
+
+## Verified clean (do NOT re-audit)
+
+- SQL injection โ Drizzle used correctly, no string concat found.
+- SSRF โ `github-client.ts` uses Octokit only, no user-controlled URL reaches `fetch`.
+- Path traversal in icons โ resolved-path prefix check plus `ICON_NAME_REGEX` is solid.
+- Dependencies โ `npm audit` clean as of 2026-08-24; recent overrides current.
+- Secrets โ `.env` gitignored; no secret values logged.
+- Visitor cache-control โ correctly `no-store` at `src/modules/badges/badges.controller.ts:139-144`.
diff --git a/docs/collections/postman_collection.json b/docs/collections/postman_collection.json
index 5851bf0..c874c8f 100644
--- a/docs/collections/postman_collection.json
+++ b/docs/collections/postman_collection.json
@@ -1,293 +1,707 @@
{
"info": {
+ "_postman_id": "a179f2a8-7baf-415d-b25d-05205a3e3508",
"name": "GitHub Stats API",
+ "description": "Postman collection for GitHub Stats routes",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
- "description": "Postman collection for GitHub Stats routes"
+ "_exporter_id": "16827919",
+ "_collection_link": "https://go.postman.co/collection/16827919-a179f2a8-7baf-415d-b25d-05205a3e3508?source=collection_link"
},
"item": [
{
"name": "Root",
"request": {
"method": "GET",
+ "header": [],
"url": {
"raw": "{{baseUrl}}/",
- "host": ["{{baseUrl}}"],
- "path": [""]
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ ""
+ ]
}
- }
+ },
+ "response": []
+ },
+ {
+ "name": "Users - List (paginated)",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/users?page=1&limit=30",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "users"
+ ],
+ "query": [
+ {
+ "key": "page",
+ "value": "1",
+ "description": "1-based page number. Takes precedence over `offset` when both are supplied."
+ },
+ {
+ "key": "limit",
+ "value": "30",
+ "description": "Items per page. Default 30, max 500."
+ },
+ {
+ "key": "offset",
+ "value": "0",
+ "description": "Zero-based offset. Ignored when `page` is provided.",
+ "disabled": true
+ }
+ ]
+ },
+ "description": "List all usernames tracked by the stats service with their GitHub avatar URLs (https://github.com/{username}.png). Response includes pagination metadata: total, limit, offset, page, total_pages, has_next, has_prev."
+ },
+ "response": []
+ },
+ {
+ "name": "Users - Get badge by username",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/users/pphatdev/badge",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "users",
+ "pphatdev",
+ "badge"
+ ]
+ },
+ "description": "Return the stored badge counters for a single user from the `badges` table (visitors, repositories, followers, total_stars, total_commits, total_issues, total_pull_requests, updated_at, etc.). Returns 404 if the user has no badge row yet."
+ },
+ "response": []
},
{
"name": "Stats",
"request": {
"method": "GET",
+ "header": [],
"url": {
"raw": "{{baseUrl}}/stats?username=pphatdev&theme=dark",
- "host": ["{{baseUrl}}"],
- "path": ["stats"],
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "stats"
+ ],
"query": [
- { "key": "username", "value": "pphatdev" },
- { "key": "theme", "value": "dark" }
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "theme",
+ "value": "dark"
+ }
]
}
- }
+ },
+ "response": []
},
{
"name": "Languages",
"request": {
"method": "GET",
+ "header": [],
"url": {
"raw": "{{baseUrl}}/languages?username=pphatdev&theme=default",
- "host": ["{{baseUrl}}"],
- "path": ["languages"],
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "languages"
+ ],
"query": [
- { "key": "username", "value": "pphatdev" },
- { "key": "theme", "value": "default" }
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "theme",
+ "value": "default"
+ }
]
}
- }
+ },
+ "response": []
},
{
"name": "Graph",
"request": {
"method": "GET",
+ "header": [],
"url": {
"raw": "{{baseUrl}}/graph?username=pphatdev&animate=wave",
- "host": ["{{baseUrl}}"],
- "path": ["graph"],
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "graph"
+ ],
"query": [
- { "key": "username", "value": "pphatdev" },
- { "key": "animate", "value": "wave" }
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "animate",
+ "value": "wave"
+ }
]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Visitors",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/visitors?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "visitors"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=visitors",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "visitors"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Repositories",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/repositories?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "repositories"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=repositories",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "repositories"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Organization",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/organization?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "organization"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=organization",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "organization"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Languages",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/languages?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "languages"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=languages",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "languages"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Followers",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/followers?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "followers"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=followers",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "followers"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Total Stars",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/total-stars?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "total-stars"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=total-stars",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "total-stars"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Total Contributors",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/total-contributors?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "total-contributors"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=total-contributors",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "total-contributors"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Total Commits",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/total-commits?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "total-commits"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=total-commits",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "total-commits"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Total Code Reviews",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/total-code-reviews?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "total-code-reviews"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=total-code-reviews",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "total-code-reviews"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Total Issues",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/total-issues?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "total-issues"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=total-issues",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "total-issues"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Total Pull Requests",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/total-pull-requests?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "total-pull-requests"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=total-pull-requests",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "total-pull-requests"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Badge - Total Joined Years",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/badge/total-joined-years?username=pphatdev",
- "host": ["{{baseUrl}}"],
- "path": ["badge", "total-joined-years"],
- "query": [{ "key": "username", "value": "pphatdev" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=total-joined-years",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "total-joined-years"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Project Badge - Stars",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/project/stars?repo=pphatdev/github-stats",
- "host": ["{{baseUrl}}"],
- "path": ["project", "stars"],
- "query": [{ "key": "repo", "value": "pphatdev/github-stats" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=stars&repo=pphatdev/github-stats",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "stars"
+ },
+ {
+ "key": "repo",
+ "value": "pphatdev/github-stats"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Project Badge - Forks",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/project/forks?repo=pphatdev/github-stats",
- "host": ["{{baseUrl}}"],
- "path": ["project", "forks"],
- "query": [{ "key": "repo", "value": "pphatdev/github-stats" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=forks&repo=pphatdev/github-stats",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "forks"
+ },
+ {
+ "key": "repo",
+ "value": "pphatdev/github-stats"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Project Badge - Watchers",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/project/watchers?repo=pphatdev/github-stats",
- "host": ["{{baseUrl}}"],
- "path": ["project", "watchers"],
- "query": [{ "key": "repo", "value": "pphatdev/github-stats" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=watchers&repo=pphatdev/github-stats",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "watchers"
+ },
+ {
+ "key": "repo",
+ "value": "pphatdev/github-stats"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Project Badge - Issues",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/project/issues?repo=pphatdev/github-stats",
- "host": ["{{baseUrl}}"],
- "path": ["project", "issues"],
- "query": [{ "key": "repo", "value": "pphatdev/github-stats" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=issues&repo=pphatdev/github-stats",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "issues"
+ },
+ {
+ "key": "repo",
+ "value": "pphatdev/github-stats"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Project Badge - PRs",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/project/prs?repo=pphatdev/github-stats",
- "host": ["{{baseUrl}}"],
- "path": ["project", "prs"],
- "query": [{ "key": "repo", "value": "pphatdev/github-stats" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=pull-requests&repo=pphatdev/github-stats",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "pull-requests"
+ },
+ {
+ "key": "repo",
+ "value": "pphatdev/github-stats"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Project Badge - Contributors",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/project/contributors?repo=pphatdev/github-stats",
- "host": ["{{baseUrl}}"],
- "path": ["project", "contributors"],
- "query": [{ "key": "repo", "value": "pphatdev/github-stats" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=contributors&repo=pphatdev/github-stats",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "contributors"
+ },
+ {
+ "key": "repo",
+ "value": "pphatdev/github-stats"
+ }
+ ]
}
- }
+ },
+ "response": []
},
{
"name": "Project Badge - Size",
"request": {
"method": "GET",
+ "header": [],
"url": {
- "raw": "{{baseUrl}}/project/size?repo=pphatdev/github-stats",
- "host": ["{{baseUrl}}"],
- "path": ["project", "size"],
- "query": [{ "key": "repo", "value": "pphatdev/github-stats" }]
+ "raw": "{{baseUrl}}/badges?username=pphatdev&name=size&repo=pphatdev/github-stats",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "badges"
+ ],
+ "query": [
+ {
+ "key": "username",
+ "value": "pphatdev"
+ },
+ {
+ "key": "name",
+ "value": "size"
+ },
+ {
+ "key": "repo",
+ "value": "pphatdev/github-stats"
+ }
+ ]
}
- }
+ },
+ "response": []
}
],
"variable": [
@@ -296,4 +710,4 @@
"value": "http://localhost:3000"
}
]
-}
+}
\ No newline at end of file
diff --git a/docs/docker-build.md b/docs/docker-build.md
new file mode 100644
index 0000000..5ef28e1
--- /dev/null
+++ b/docs/docker-build.md
@@ -0,0 +1,167 @@
+# Docker Build and Run Guide
+
+The project ships with a production-ready multi-stage `Dockerfile` using `node:20-bookworm-slim`. It installs dependencies, compiles TypeScript, prunes devDependencies, then copies only the runtime artifacts into a clean final image.
+
+## Prerequisites
+
+- Docker installed (`docker --version`)
+- Optional: a `.env` file in project root
+
+## Build and Run
+
+Build:
+
+```bash
+docker build -f Dockerfile -t github-stats:node .
+```
+
+Compose:
+
+```bash
+docker compose up --build
+```
+
+Run:
+
+```bash
+docker run --rm -p 3000:3000 --env-file .env github-stats:node
+```
+
+Open:
+
+- API root: `http://localhost:3000/`
+- Health check: `http://localhost:3000/health`
+
+> **Note:** `APP_ENV` must be exactly `development`, `production`, or `test`. Any other value (e.g. `prod`, `staging`, or extra whitespace) will fail startup validation. You can override a bad value in `.env` at runtime:
+> ```bash
+> docker run --rm -p 3000:3000 --env-file .env -e APP_ENV=production github-stats:node
+> ```
+
+## Detached Mode and Logs
+
+Run in background:
+
+```bash
+docker run -d --name github-stats -p 3000:3000 --env-file .env github-stats:node
+```
+
+View logs:
+
+```bash
+docker logs -f github-stats
+```
+
+Compose logs:
+
+```bash
+docker compose logs -f
+```
+
+Stop and remove:
+
+```bash
+docker rm -f github-stats
+```
+
+Compose stop:
+
+```bash
+docker compose down
+```
+
+## Important Environment Variables
+
+Defaults are defined in `src/shared/config/env.ts`, so the app can run with minimal configuration. Recommended variables for production:
+
+- `NODE_ENV=production`
+- `APP_ENV=production`
+- `PORT=3000`
+- `HOST=0.0.0.0`
+- `GITHUB_TOKEN` (recommended to avoid strict GitHub API rate limits)
+
+Optional cache and storage variables:
+
+- Redis: `REDIS_URL` (or `REDIS_HOST`/`REDIS_PORT`)
+- Database provider: `DATABASE_PROVIDER`, `DATABASE_URL`
+
+## Useful Commands
+
+Rebuild without cache:
+
+```bash
+docker build --no-cache -f Dockerfile -t github-stats:node .
+```
+
+Check image size:
+
+```bash
+docker images github-stats
+```
+
+## Releasing a New Image
+
+### 1. Bump the version
+
+Update the `version` field in `package.json` before tagging a release (current: `2.1.1`).
+
+### 2. Build and tag
+
+**Node.js image (`Dockerfile`):**
+
+```bash
+docker build -t pphatdev/github-stats:2.1.1 -t pphatdev/github-stats:latest .
+```
+
+**Bun image (`Dockerfile.bun`):**
+
+```bash
+docker build -f Dockerfile.bun -t pphatdev/github-stats:2.1.1-bun -t pphatdev/github-stats:latest-bun .
+```
+
+> **Cross-platform tip:** Add `--platform linux/amd64` when building on Apple Silicon for Linux server compatibility:
+> ```bash
+> docker build --platform linux/amd64 -t pphatdev/github-stats:latest .
+> ```
+
+### 3. Push to registry
+
+```bash
+docker push pphatdev/github-stats:2.1.1
+docker push pphatdev/github-stats:latest
+```
+
+### 4. Deploy on a remote server
+
+Pull and restart the container:
+
+```bash
+docker pull pphatdev/github-stats:latest
+docker stop github-stats && docker rm github-stats
+docker run -d --name github-stats -p 3000:3000 --env-file .env pphatdev/github-stats:latest
+```
+
+Or with Docker Compose:
+
+```bash
+docker compose pull
+docker compose up -d --force-recreate
+```
+
+The repository includes a `compose.yaml` for local development and self-hosting. It starts:
+
+- `app`: the Node.js API built from the local `Dockerfile`
+- `redis`: a local Redis 7 instance for cache storage
+
+The compose setup also forces local-safe defaults that differ from the Cloudflare deployment path:
+
+- `DATABASE_PROVIDER=sqlite`
+- `DATABASE_URL=/app/data/stats.db`
+- `REDIS_HOST=redis`
+
+If you have a `.env` file with a `GITHUB_TOKEN`, Docker Compose will pass it through to the app container.
+
+## Notes
+
+- `.dockerignore` excludes common local artifacts (`node_modules`, `dist`, `.git`, `.env`, and more) to keep build context small.
+- The multi-stage build uses three stages: `deps` (install), `build` (`npm ci` + `tsc` + `npm prune --omit=dev`), and `runtime` (lean final image).
+- `APP_ENV`, `NODE_ENV` must each be one of `development`, `production`, or `test` โ any other value fails startup validation.
\ No newline at end of file
diff --git a/docs/example/README.md b/docs/example/README.md
index 1148f35..ee684e2 100644
--- a/docs/example/README.md
+++ b/docs/example/README.md
@@ -9,12 +9,13 @@ This folder contains one file per route, with demo examples for each available o
- [GET /graph](./graph.md)
- [GET /icons and /icons/:name](./icons.md)
- [GET /icons (Collection Mode)](./icon-collection.md)
-- [GET /badge/:type](./badge-user.md)
+- [GET /badges](./badge-user.md)
+- [GET /badges (multi-badge layout)](./badge-collection.md)
- [GET /project/:type](./project.md)
## Additional Guides
-- [Release Icons Documentation](../how-to/RELEASE_ICONS.md) - How to use icons in releases, changelogs, and READMEs
+- [Release Icons Documentation](../RELEASE/RELEASE_ICONS.md) - How to use icons in releases, changelogs, and READMEs
## Notes
diff --git a/docs/example/badge-collection.md b/docs/example/badge-collection.md
new file mode 100644
index 0000000..490ac81
--- /dev/null
+++ b/docs/example/badge-collection.md
@@ -0,0 +1,104 @@
+# GET /badges
+
+Render one or more badges into a single SVG image.
+
+## Route
+
+- `/badges?username=pphatdev&name=visitors,total-stars,repositories`
+
+## Required Query Params
+
+| Param | Description |
+|---|---|
+| `username` | GitHub username |
+
+## Optional Query Params
+
+| Param | Description |
+|---|---|
+| `repo` | Repository name for repo-level badges |
+| `name` | Comma-separated badge names |
+| `theme` | One or more comma-separated themes, cycled across badges |
+| `effect` | `wave` or `glow` |
+| `column` | Number of columns in the grid (`1-50`) |
+| `size` | `small`, `medium`, or `large` |
+| `customLabel` | Custom label text applied to all badges |
+| `labelColor` | Label text color |
+| `labelBackground` | Label background color |
+| `iconColor` | Icon color |
+| `valueColor` | Value text color |
+| `valueBackground` | Value background color |
+
+## Basic Examples
+
+Two badges in one SVG:
+
+
+
+Multiple badges with 3 columns:
+
+
+
+## Layout Examples
+
+Custom columns:
+
+
+
+Single-row layout:
+
+
+
+Large size preset:
+
+
+
+## Style Examples
+
+Theme cycling:
+
+
+
+Glow effect:
+
+
+
+Wave effect:
+
+
+
+Custom colors:
+
+
+
+## Mixed User + Repo Example
+
+
+
+## Curl Examples
+
+```bash
+curl "https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars"
+curl "https://stats.pphat.top/badges?username=pphatdev&name=visitors,repositories,followers,total-stars&column=2"
+curl "https://stats.pphat.top/badges?username=pphatdev&repo=github-stats&name=visitors,stars,forks&theme=ocean,aurora&effect=glow"
+```
+
+## Error Examples
+
+Missing username:
+
+```bash
+curl "https://stats.pphat.top/badges?name=visitors,total-stars"
+```
+
+Missing repo for repo badge:
+
+```bash
+curl "https://stats.pphat.top/badges?username=pphatdev&name=stars,forks"
+```
+
+Invalid name:
+
+```bash
+curl "https://stats.pphat.top/badges?username=pphatdev&name=visitors,unknown-badge"
+```
diff --git a/docs/example/badge-user.md b/docs/example/badge-user.md
index a869d21..0d372cc 100644
--- a/docs/example/badge-user.md
+++ b/docs/example/badge-user.md
@@ -1,21 +1,10 @@
-# GET /badge/:type
+# GET /badges
-Generate user metric badges.
+Generate one or more badges from a single query-style endpoint.
-## Route Types
+## Route
-- `/badge/visitors`
-- `/badge/repositories`
-- `/badge/organization`
-- `/badge/languages`
-- `/badge/followers`
-- `/badge/total-stars`
-- `/badge/total-contributors`
-- `/badge/total-commits`
-- `/badge/total-code-reviews`
-- `/badge/total-issues`
-- `/badge/total-pull-requests`
-- `/badge/total-joined-years`
+- `/badges?username=pphatdev&name=visitors`
## Required Params
@@ -25,7 +14,12 @@ Generate user metric badges.
| Param | Description |
|---|---|
-| `theme` | Badge theme |
+| `repo` | Repository name for repo-level badges such as `stars` or `forks` |
+| `name` | Comma-separated badge names |
+| `theme` | One theme or multiple comma-separated themes cycled across badges |
+| `effect` | `wave` or `glow` |
+| `column` | Grid columns for multi-badge output (`1-50`) |
+| `size` | `small`, `medium`, or `large` |
| `customLabel` | Custom label text |
| `labelColor` | Label text color |
| `labelBackground` | Label background color |
@@ -35,37 +29,59 @@ Generate user metric badges.
| `hideFrame` | Hide frame (`true`/`false`) |
| `hideIcon` | Hide icon (`true`/`false`) |
-## Demo Each Route Type
+## Supported User Badge Names
-
-
-
-
-
-
-
-
-
-
-
-
+- `visitors`
+- `repositories`
+- `organization`
+- `languages`
+- `followers`
+- `total-stars`
+- `total-contributors`
+- `total-commits`
+- `total-code-reviews`
+- `total-issues`
+- `total-pull-requests`
+- `total-joined-years`
-## Demo Each Optional Param
+## Supported Repo Badge Names
+
+- `stars`
+- `forks`
+- `contributors`
+- `issues`
+- `pull-requests`
+- `watchers`
+- `size`
+
+## Single Badge Examples
+
+
+
+
+
+
+## Repo Badge Examples
+
+
+
+
+## Style Examples
| Param | Preview |
|---|---|
-| `theme` |  |
-| `customLabel` |  |
-| `labelColor` |  |
-| `labelBackground` |  |
-| `iconColor` |  |
-| `valueColor` |  |
-| `valueBackground` |  |
-| `hideFrame` |  |
-| `hideIcon` |  |
-
-## Combined Demos
-
-
-
-
+| `theme` |  |
+| `customLabel` |  |
+| `labelColor` |  |
+| `labelBackground` |  |
+| `iconColor` |  |
+| `valueColor` |  |
+| `valueBackground` |  |
+| `hideFrame` |  |
+| `hideIcon` |  |
+
+## Combined Examples
+
+
+
+
diff --git a/docs/features/badges.md b/docs/features/badges.md
new file mode 100644
index 0000000..93bf611
--- /dev/null
+++ b/docs/features/badges.md
@@ -0,0 +1,115 @@
+# Badges Features
+
+Generate customizable GitHub badges for users and repositories with real-time data, caching, and visual effects.
+
+## Route Pattern
+
+`/badges?username={username}&repo={repo}&name={visitors,total-stars,...}&theme={theme1,theme2,...}&effect={wave|glow}&column={1-50}&size={small|medium|large}&p={0-100}`
+
+## Parameters
+
+### Required
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `username` | โ | The GitHub username for which to generate badges. |
+
+### Optional
+
+| Parameter | Default | Range | Description |
+|-----------|---------|-------|-------------|
+| `repo` | โ | โ | The repository name for which to generate badges. Format: `repo-name` or `owner/repo-name`. |
+| `name` | โ | โ | Comma-separated badge names to generate (e.g., `visitors,total-stars,repositories`). |
+| `theme` | `default` | โ | Comma-separated theme(s) to apply to badges (e.g., `default,ocean,galaxy`). |
+| `effect` | โ | `wave`, `glow` | Animation effect applied to all badges. |
+| `column` | `50` | 1-50 | Number of columns for badge grid layout. |
+| `size` | `small` | `small`, `medium`, `large` | Badge size multiplier. |
+| `p` | `0` | 0-100 | Container padding in pixels around the badge grid. |
+| `realtime` | `false` | โ | Bypass cache and fetch fresh data (30s cooldown). |
+
+## Supported Badge Types
+
+### User Badges
+
+Fetch GitHub user statistics:
+
+- `visitors` โ Unique visitor count (persisted per user)
+- `repositories` โ Total repositories count
+- `followers` โ Follower count
+- `organization` โ Organizations count
+- `languages` โ Programming languages count
+- `total-stars` โ Stars received across all repos
+- `total-contributors` โ Contributors count across all repos
+- `total-commits` โ Total commits count
+- `total-code-reviews` โ Code reviews count
+- `total-issues` โ Issues count
+- `total-pull-requests` โ Pull requests count
+- `total-joined-years` โ Years since account creation
+
+### Repository Badges
+
+Fetch GitHub repository statistics:
+
+- `stars` โ Repository stars count
+- `forks` โ Repository forks count
+- `contributors` โ Repository contributors count
+- `issues` โ Open issues count
+- `pull-requests` โ Pull requests count
+- `watchers` โ Repository watchers count
+- `size` โ Repository size
+
+## Usage Examples
+
+### Basic User Badge
+
+```
+/badges?username=pphatdev&name=visitors
+```
+
+### Multiple badges with custom theme
+
+```
+/badges?username=pphatdev&name=visitors,total-stars,followers&theme=ocean&column=3&size=medium
+```
+
+### Repository badges
+
+```
+/badges?username=pphatdev&repo=github-stats&name=stars,forks,contributors&theme=galaxy&effect=wave
+```
+
+### With padding and sizing
+
+```
+/badges?username=pphatdev&name=visitors,repositories,followers&p=15&size=large&column=2
+```
+
+### Multiple themes (one per badge)
+
+```
+/badges?username=pphatdev&name=visitors,total-stars,repositories&theme=default,ocean,galaxy&column=1
+```
+
+### Fresh data with realtime flag
+
+```
+/badges?username=pphatdev&name=total-stars&realtime=true
+```
+
+## Response
+
+The API returns an SVG image with combined badges arranged in a grid layout. The response includes appropriate cache headers:
+
+- Default cache: 10 minutes (`max-age=600`)
+- Visitor badges: Updated on every request (counter incremented)
+- Background refresh: Triggers after 15 seconds of staleness
+- Rate-limit protection: 2-minute cooldown per cache key, 30-second cooldown per realtime request
+
+## Performance Notes
+
+- Badges are cached in-memory with 10-minute TTL
+- Stale-While-Revalidate (SWR) pattern ensures fast responses with background refresh
+- GitHub API calls are deduplicated to prevent rate-limit exhaustion
+- Visitor counter increments on every request (database-backed, no API calls)
+- Use `realtime=true` to bypass cache (subject to 30s cooldown per cache key)
+
diff --git a/docs/how-to/CACHE_MONITORING.md b/docs/how-to/CACHE_MONITORING.md
deleted file mode 100644
index d4fb84a..0000000
--- a/docs/how-to/CACHE_MONITORING.md
+++ /dev/null
@@ -1,555 +0,0 @@
-# Cache Monitoring & Health Check Guide
-
-This guide covers the cache monitoring and health check endpoints for the GitHub Stats API. Use these endpoints to monitor cache performance, troubleshoot issues, and understand caching strategies.
-
-## Table of Contents
-- [GET /cache/health](#get-cachehealth---health-status)
-- [GET /cache/stats](#get-cachestats---detailed-statistics)
-- [Cache Architecture](#cache-architecture)
-- [Troubleshooting](#troubleshooting)
-- [Performance Optimization](#performance-optimization)
-
----
-
-## GET /cache/health - Health Status
-
-Provides a quick health check of the cache system and returns connection status for all cache layers.
-
-### Endpoint
-```
-GET /cache/health
-```
-
-### Parameters
-None required. No parameters accepted.
-
-### Response
-**Content-Type:** `application/json`
-
-Returns a JSON object with cache health information:
-
-```json
-{
- "status": "ok|degraded|error",
- "badge_cache": {
- "connected": true|false,
- "health": "healthy|degraded|offline",
- "db_size": 1024000,
- "memory": "42MB"
- },
- "cache_strategies": {
- "description": "TTL strategies for different badge types (in seconds)",
- "strategies": {
- "visitors": 1800,
- "repositories": 1800,
- "followers": 3600,
- "total_stars": 3600,
- "total_commits": 3600,
- "...": "..."
- }
- },
- "timestamp": "2026-03-05T10:30:00.000Z"
-}
-```
-
-### Examples
-
-#### Basic Health Check
-```bash
-curl "http://localhost:3000/cache/health"
-```
-
-#### Pretty Print (using jq)
-```bash
-curl "http://localhost:3000/cache/health" | jq .
-```
-
-#### Check Status Only
-```bash
-curl -s "http://localhost:3000/cache/health" | jq .status
-```
-
-### Response Fields
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `status` | string | Overall cache status: `ok`, `degraded`, or `error` |
-| `badge_cache.connected` | boolean | Is Redis cache connected |
-| `badge_cache.health` | string | Cache health state |
-| `badge_cache.db_size` | number | Database storage size in bytes |
-| `badge_cache.memory` | string | Memory usage (if available) |
-| `cache_strategies` | object | TTL configuration for all badge types |
-| `timestamp` | string | ISO timestamp of the response |
-
-### Status Meanings
-
-| Status | Meaning | Action |
-|--------|---------|--------|
-| `ok` | All systems healthy | None needed |
-| `degraded` | Some cache layers offline | Check Redis connection |
-| `error` | Major cache failure | Check server logs |
-
----
-
-## GET /cache/stats - Detailed Statistics
-
-Provides comprehensive cache statistics, optimization notes, and performance recommendations.
-
-### Endpoint
-```
-GET /cache/stats
-```
-
-### Parameters
-None required. No parameters accepted.
-
-### Response
-**Content-Type:** `application/json`
-
-Returns detailed cache statistics:
-
-```json
-{
- "cache_statistics": {
- "connected": true|false,
- "db_size": 1024000,
- "memory_usage": "42MB",
- "health": "healthy"
- },
- "optimization_notes": {
- "redis_benefits": [
- "Persistent SVG cache across server restarts",
- "Distributed cache for multi-worker deployments",
- "Automatic TTL-based eviction reduces memory overhead",
- "Intelligent TTL management based on data freshness"
- ],
- "caching_layers": [
- {
- "layer": "Redis",
- "scope": "Persistent - survives restarts",
- "ttl": "Adaptive (1min-2hrs based on data)",
- "use_case": "Primary cache for rendered SVGs"
- },
- {
- "layer": "In-Memory Map",
- "scope": "Process memory",
- "ttl": "600-3600 seconds",
- "use_case": "Secondary cache for request deduplication"
- },
- {
- "layer": "Database",
- "scope": "SQLite persistence",
- "ttl": "2 hours max",
- "use_case": "Source of truth for badge values"
- }
- ]
- },
- "recommendations": {
- "if_memory_high": "Reduce TTL values for less-critical badges",
- "if_cache_misses_high": "Increase TTL values or enable warmup",
- "if_disconnected": "Check Redis connection - falling back to in-memory"
- },
- "timestamp": "2026-03-05T10:30:00.000Z"
-}
-```
-
-### Examples
-
-#### Get Full Statistics
-```bash
-curl "http://localhost:3000/cache/stats"
-```
-
-#### Pretty Print with jq
-```bash
-curl "http://localhost:3000/cache/stats" | jq .
-```
-
-#### Check Cache Size
-```bash
-curl -s "http://localhost:3000/cache/stats" | jq '.cache_statistics.db_size'
-```
-
-#### View Recommendations
-```bash
-curl -s "http://localhost:3000/cache/stats" | jq '.recommendations'
-```
-
-### Cache Layers Explained
-
-#### 1. Redis Persistent Cache
-- **Purpose:** Primary cache for rendered SVG/WebP images
-- **Scope:** Survives server restarts
-- **TTL:** 1 minute to 2 hours (adaptive)
-- **Use Case:** Long-term caching of frequently accessed badges
-- **Benefits:**
- - Persistent across deployments
- - Supports distributed deployments
- - Automatic TTL eviction
- - Shared across worker processes
-
-#### 2. In-Memory Cache
-- **Purpose:** Fast request deduplication
-- **Scope:** Process memory only
-- **TTL:** 600-3600 seconds (10 min - 1 hour)
-- **Use Case:** Prevent duplicate API calls during processing
-- **Benefits:**
- - Fastest access time
- - Request coalescing
- - Reduces GitHub API calls
-
-#### 3. Database Cache
-- **Purpose:** Source of truth for computed metrics
-- **Scope:** SQLite persistent storage
-- **TTL:** Max 2 hours
-- **Use Case:** Fallback when Redis unavailable
-- **Benefits:**
- - Reliable persistence
- - Historical data tracking
- - Works without Redis
-
----
-
-## Cache Architecture
-
-### Multi-Layer Cache Strategy
-
-```
-Request
- โ
-In-Memory Cache (fast)
- โโ HIT โ Return immediately
- โโ MISS
- โ
- Redis Cache (persistent)
- โโ HIT โ Return & update in-memory
- โโ MISS
- โ
- Database
- โโ HIT โ Return & update layers
- โโ MISS โ Compute & cache all layers
-```
-
-### TTL Strategy
-
-Different badges have different TTL values based on update frequency:
-
-| Badge Type | TTL | Reason |
-|-----------|-----|--------|
-| `visitors` | 10 min | Changes frequently |
-| `followers` | 1 hour | Updates moderately |
-| `repositories` | 1 hour | Stable metric |
-| `total-stars` | 2 hours | Changes slowly |
-| `total-commits` | 2 hours | Changes slowly |
-| `language-breakdown` | 2 hours | Changes slowly |
-
-### Request Deduplication
-
-The in-memory cache prevents duplicate processing:
-- Multiple identical requests are queued
-- First request triggers computation
-- Results shared with all queued requests
-- Reduces GitHub API calls by 60-80%
-
----
-
-## Monitoring & KPIs
-
-### Key Performance Indicators
-
-1. **Cache Hit Rate**
- - Indicates cache effectiveness
- - Healthy target: > 80%
-
-2. **Memory Usage**
- - In-Memory: Should stay < 100MB
- - Redis: Should scale with data volume
-
-3. **Response Time**
- - Cached: < 100ms
- - Uncached: 500ms - 2 seconds
- - Timeout limit: 30 seconds
-
-4. **Database Size**
- - Typical: 10MB - 100MB
- - For 1000 users: ~50MB
-
-### Monitoring Example
-
-```bash
-#!/bin/bash
-echo "Cache Health Check"
-echo "=================="
-
-HEALTH=$(curl -s http://localhost:3000/cache/health)
-STATUS=$(echo $HEALTH | jq -r '.status')
-DB_SIZE=$(echo $HEALTH | jq '.badge_cache.db_size')
-
-echo "Status: $STATUS"
-echo "DB Size: $((DB_SIZE / 1024 / 1024))MB"
-
-if [ "$STATUS" != "ok" ]; then
- echo "โ ๏ธ Warning: Cache is not healthy!"
- echo $HEALTH | jq '.'
-fi
-```
-
----
-
-## Troubleshooting
-
-### Issue: Cache Status is "degraded" or "error"
-
-#### Check Redis Connection
-```bash
-# The application falls back gracefully to in-memory caching
-# Check server logs for connection errors
-```
-
-#### Possible Causes & Fixes
-
-| Issue | Symptoms | Fix |
-|-------|----------|-----|
-| Redis offline | `connected: false` | Start Redis server |
-| Wrong Redis URL | Connection timeout | Check `REDIS_URL` env var |
-| Redis memory full | Slow responses | Clear cache or increase memory |
-| Network issue | Intermittent failures | Check firewall/network |
-
-#### Recovery Steps
-```bash
-# 1. Check health
-curl http://localhost:3000/cache/health
-
-# 2. Check Redis connection (if available)
-redis-cli ping
-
-# 3. Restart app if needed
-npm restart
-```
-
-### Issue: Memory Usage is Too High
-
-#### Check Database Size
-```bash
-curl -s http://localhost:3000/cache/stats | jq '.cache_statistics.db_size'
-```
-
-#### Solutions
-1. **Reduce TTL values** for non-critical badges
-2. **Clear old cache entries** manually
-3. **Increase server memory** allocation
-4. **Enable cache cleanup** jobs
-
-### Issue: Slow Response Times
-
-#### Diagnose Problem
-```bash
-# Check cache hit rate
-curl -s http://localhost:3000/cache/stats | jq '.cache_statistics'
-```
-
-#### Common Causes & Fixes
-
-| Cause | Fix |
-|-------|-----|
-| Cache miss due to low TTL | Increase TTL for stable badges |
-| Redis connection slow | Check Redis server performance |
-| GitHub API slow | Retry with cache warmer |
-| Database queries slow | Optimize database indexes |
-
-#### Performance Tuning
-```bash
-# Enable cache warming for popular users
-# Contact admin team for configuration
-```
-
-### Issue: Cache Not Updating
-
-#### Check Cache TTL
-```bash
-# Different badges have different TTL
-curl -s http://localhost:3000/cache/health | \
- jq '.cache_strategies.strategies'
-```
-
-#### Possible Causes
-1. **TTL hasn't expired** - Wait for TTL period
-2. **Redis cache stuck** - Check Redis commands
-3. **Database Update failed** - Check server logs
-4. **Bug in update logic** - Report to developers
-
----
-
-## Performance Optimization
-
-### Cache Warmup
-
-Pre-populate cache for frequently accessed users:
-
-```bash
-#!/bin/bash
-# warm-cache.sh
-
-USERS=("pphatdev" "torvalds" "gvanrossum")
-
-for user in "${USERS[@]}"; do
- echo "Warming cache for: $user"
- curl -s "http://localhost:3000/stats?username=$user" > /dev/null
- curl -s "http://localhost:3000/badge/followers?username=$user" > /dev/null
- curl -s "http://localhost:3000/badge/total-stars?username=$user" > /dev/null
-done
-
-echo "Cache warmup complete!"
-```
-
-### Monitoring Script
-
-```bash
-#!/bin/bash
-# monitor-cache.sh
-
-while true; do
- clear
- echo "Cache Monitoring Dashboard"
- echo "=========================="
- echo "Timestamp: $(date)"
- echo ""
-
- curl -s http://localhost:3000/cache/health | jq '{
- status: .status,
- connected: .badge_cache.connected,
- memory: .badge_cache.memory,
- db_size: .badge_cache.db_size
- }'
-
- echo ""
- echo "Press Ctrl+C to exit..."
- sleep 5
-done
-```
-
-### Optimization Checklist
-
-- [ ] Verify Redis is running and configured
-- [ ] Monitor memory usage weekly
-- [ ] Check cache hit rates
-- [ ] Review TTL settings for your use case
-- [ ] Set up cache warming for popular users
-- [ ] Monitor database size growth
-- [ ] Plan capacity for expected growth
-
----
-
-## Configuration
-
-### Environment Variables
-
-```bash
-# Cache Configuration
-CACHE_TTL_DEFAULT=3600 # Default TTL in seconds
-CACHE_TTL_STATS=3600 # Stats card TTL
-CACHE_TTL_LANGUAGES=5400 # Languages badge TTL
-CACHE_TTL_GRAPH=7200 # Graph TTL
-
-# Redis Configuration
-REDIS_URL=redis://localhost:6379
-REDIS_PASSWORD= # Optional
-REDIS_DB=0 # Database number
-
-# Cache Behavior
-CACHE_ENABLE_WARMUP=false # Enable automatic cache warmup
-CACHE_WARMUP_USERS= # Comma-separated list
-```
-
-### Advanced Settings
-
-```typescript
-// TTL Strategies (from badge-cache-manager.ts)
-CACHE_TTL_STRATEGIES: {
- 'visitors': 600, // 10 minutes
- 'repositories': 3600, // 1 hour
- 'followers': 3600, // 1 hour
- 'total-stars': 7200, // 2 hours
- 'total-commits': 7200, // 2 hours
- // ... more badges
-}
-```
-
----
-
-## Best Practices
-
-### 1. Regular Monitoring
-```bash
-# Set up a cron job to monitor cache health
-0 */6 * * * curl http://localhost:3000/cache/health | mail -s "Cache Health" admin@example.com
-```
-
-### 2. Alerts & Notifications
-- Set up alerts when `status != "ok"`
-- Monitor memory usage trends
-- Track cache size growth
-
-### 3. Capacity Planning
-- Plan for 10MB per 200 users
-- Monitor growth rate
-- Scale Redis memory proactively
-
-### 4. Maintenance
-- Clear cache if needed during updates
-- Schedule cleanups during off-peak hours
-- Monitor logs for errors
-
-### 5. Optimization
-- Use appropriate TTL values
-- Enable request deduplication
-- Pre-warm cache for popular content
-
----
-
-## Integration Examples
-
-### Nagios / Monitoring System
-
-```bash
-#!/bin/bash
-# check_cache_health.sh
-
-RESPONSE=$(curl -s http://localhost:3000/cache/health)
-STATUS=$(echo $RESPONSE | jq -r '.status')
-
-if [ "$STATUS" == "ok" ]; then
- echo "OK - Cache is healthy"
- exit 0
-elif [ "$STATUS" == "degraded" ]; then
- echo "WARNING - Cache is degraded"
- exit 1
-else
- echo "CRITICAL - Cache error"
- exit 2
-fi
-```
-
-### Prometheus Metrics Export
-
-```bash
-#!/bin/bash
-# export_metrics.sh
-
-STATS=$(curl -s http://localhost:3000/cache/stats)
-DB_SIZE=$(echo $STATS | jq '.cache_statistics.db_size')
-HEALTH=$(curl -s http://localhost:3000/cache/health)
-STATUS=$(echo $HEALTH | jq -r '.status')
-
-echo "# HELP cache_db_size_bytes Cache database size"
-echo "cache_db_size_bytes $DB_SIZE"
-echo "# HELP cache_status Cache health status (1=ok, 0=degraded)"
-echo "cache_status $([ "$STATUS" == "ok" ] && echo 1 || echo 0)"
-```
-
----
-
-## See Also
-- [Core Statistics Routes](./CORE_ROUTES.md) - Main endpoints
-- [User Badge Routes](./USER_BADGES.md) - User badges
-- [Project Badge Routes](./PROJECT_BADGES.md) - Project badges
diff --git a/docs/how-to/CORE_ROUTES.md b/docs/how-to/CORE_ROUTES.md
deleted file mode 100644
index e7a9cc2..0000000
--- a/docs/how-to/CORE_ROUTES.md
+++ /dev/null
@@ -1,437 +0,0 @@
-# Core Statistics Routes
-
-These routes provide comprehensive GitHub user statistics visualizations, contribution graphs, and reusable SVG icons.
-
-## Table of Contents
-- [GET /stats](#get-stats-user-statistics-card)
-- [GET /languages](#get-languages-language-breakdown)
-- [GET /graph](#get-graph-contribution-graph)
-- [GET /icons (list)](#get-icons---list-available-icons)
-- [GET /icons/:name (svg)](#get-iconsname---get-an-icon-svg)
-- [GET /icons/demo (gallery)](#get-iconsdemo---icons-gallery-page)
-
----
-
-## GET /stats - User Statistics Card
-
-Generates a detailed GitHub user statistics card displaying various metrics and achievements.
-
-### Endpoint
-```
-GET /stats
-```
-
-### Required Parameters
-
-| Parameter | Type | Description | Example |
-|-----------|------|-------------|---------|
-| `username` | string | GitHub username | `pphatdev` |
-
-### Optional Parameters
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `theme` | string | `default` | Color theme for the card |
-| `hide_title` | boolean | `false` | Hide the title section |
-| `hide_border` | boolean | `false` | Hide the border |
-| `hide_rank` | boolean | `false` | Hide the rank badge |
-| `show_icons` | boolean | `true` | Display icons next to metrics |
-| `avatar_mode` | string | `none` | Avatar display mode (`none`, `avatar`, `github`) |
-| `custom_title` | string | - | Custom title text |
-| `data_border_style` | string | `solid` | Border style (`solid`, `dashed`) |
-| `data_border_frame` | string | `out` | Border frame position (`in`, `out`) |
-| `bgColor` | string | - | Custom background color (hex) |
-| `borderColor` | string | - | Custom border color (hex) |
-| `textColor` | string | - | Custom text color (hex) |
-| `titleColor` | string | - | Custom title color (hex) |
-| `format` | string | `svg` | Output format (`svg`, `webp`) |
-
-### Response
-**Content-Type:** `image/svg+xml` or `image/webp`
-
-Returns an SVG image containing:
-- User avatar (if avatar_mode enabled)
-- Username and rank badge
-- Total contributions
-- Repositories count
-- Followers count
-- GitHub join date
-- Customizable theme colors
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/stats?username=pphatdev"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/stats?username=pphatdev&theme=tokyo"
-```
-
-#### With Customization
-```bash
-curl "http://localhost:3000/stats?username=pphatdev&theme=dracula&hide_rank=true&show_icons=true&avatar_mode=avatar"
-```
-
-#### With Custom Colors
-```bash
-curl "http://localhost:3000/stats?username=pphatdev&bgColor=%23ffffff&textColor=%23000000&titleColor=%23ff0000"
-```
-
-#### WebP Format
-```bash
-curl "http://localhost:3000/stats?username=pphatdev&format=webp"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
-### Available Themes
-- `default` - Default theme
-- `tokyo` - Tokyo Night theme
-- `dracula` - Dracula theme
-- `nord` - Nord theme
-- And many more...
-
-### Caching
-- **TTL:** 1 hour (configurable)
-- **Key:** `stats:{username}:{params_hash}`
-- **Layer:** Redis persistent + In-Memory
-
----
-
-## GET /languages - Language Breakdown
-
-Returns a visualization of programming languages used across a user's repositories.
-
-### Endpoint
-```
-GET /languages
-```
-
-### Required Parameters
-
-| Parameter | Type | Description | Example |
-|-----------|------|-------------|---------|
-| `username` | string | GitHub username | `pphatdev` |
-
-### Optional Parameters
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `theme` | string | `default` | Color theme |
-| `hide_border` | boolean | `false` | Hide the border |
-| `langs_count` | number | `8` | Number of languages to display |
-| `hide_rank` | boolean | `false` | Hide rank badge |
-| `customLabel` | string | - | Custom label text |
-| `bgColor` | string | - | Custom background color (hex) |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Returns an SVG showing:
-- Top programming languages
-- Usage percentages
-- Color-coded language indicators
-- Customizable layout and colors
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/languages?username=pphatdev"
-```
-
-#### With Custom Count
-```bash
-curl "http://localhost:3000/languages?username=pphatdev&langs_count=10"
-```
-
-#### With Theme and Customization
-```bash
-curl "http://localhost:3000/languages?username=pphatdev&theme=nord&customLabel=Top%20Languages"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
-### Caching
-- **TTL:** 1.5 hours (configurable)
-- **Key:** `languages:{username}`
-- **Layer:** Redis persistent + In-Memory
-
----
-
-## GET /graph - Contribution Graph
-
-Displays a GitHub-like contribution graph visualization for a specific time period.
-
-### Endpoint
-```
-GET /graph
-```
-
-### Required Parameters
-
-| Parameter | Type | Description | Example |
-|-----------|------|-------------|---------|
-| `username` | string | GitHub username | `pphatdev` |
-
-### Optional Parameters
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `theme` | string | `default` | Color theme |
-| `format` | string | `svg` | Output format (`svg`, `png`, `webp`) |
-| `as` | string | `svg` | Alternative format param (`svg`, `png`, `webp`) |
-| `year` | number | current | Year to display contributions for |
-| `hide_border` | boolean | `false` | Hide the border |
-| `hide_title` | boolean | `false` | Hide the title |
-| `custom_title` | string | - | Custom title text |
-
-### Response
-**Content-Type:** `image/svg+xml`, `image/png`, or `image/webp`
-
-Returns a visualization showing:
-- Weekly contribution calendar grid
-- Color intensity based on contribution count
-- Contribution statistics
-- Customizable themes and labels
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/graph?username=pphatdev"
-```
-
-#### SVG Format
-```bash
-curl "http://localhost:3000/graph?username=pphatdev&format=svg&theme=tokyo"
-```
-
-#### PNG Format
-```bash
-curl "http://localhost:3000/graph?username=pphatdev&format=png"
-```
-
-#### Specific Year
-```bash
-curl "http://localhost:3000/graph?username=pphatdev&year=2023"
-```
-
-#### Custom Title
-```bash
-curl "http://localhost:3000/graph?username=pphatdev&custom_title=My%20Contributions&theme=dracula"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
-### Caching
-- **TTL:** 2 hours (configurable)
-- **Key:** `graph:{username}:{params_hash}`
-- **Layer:** Redis persistent + In-Memory
-
----
-
-## GET /icons - List Available Icons
-
-Returns the list of all available icon names and related helper routes.
-
-### Endpoint
-```
-GET /icons
-```
-
-### Required Parameters
-
-None.
-
-### Optional Parameters
-
-None.
-
-### Response
-**Content-Type:** `application/json`
-
-Returns JSON containing:
-- Total icon count
-- `icons` array with all icon names
-- Example helper endpoints (`/icons/:name`, `/icons/:name.svg`, `/icons/demo`)
-
-### Example
-
-```bash
-curl "http://localhost:3000/icons"
-```
-
----
-
-## GET /icons/:name - Get an Icon (SVG)
-
-Returns a single SVG icon by name. Supports both `/icons/:name` and `/icons/:name.svg`.
-
-### Endpoints
-```
-GET /icons/:name
-GET /icons/:name.svg
-```
-
-### Required Parameters
-
-| Parameter | Type | Description | Example |
-|-----------|------|-------------|---------|
-| `name` | string | Icon name from `/icons` list | `react` |
-
-### Optional Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `color` | string | Replaces `currentColor` fill/stroke values |
-| `foreground` | string | Recolors elements marked with `data-foreground` |
-| `glow` | boolean | Enable glow effect (`true` or `1`) |
-| `glowColor` | string | Set glow color (hex, rgb, named). Defaults to `#00AAFF` |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Returns SVG content for the requested icon with optional color customization and glow effect.
-
-### Examples
-
-```bash
-curl "http://localhost:3000/icons/react"
-curl "http://localhost:3000/icons/react.svg"
-curl "http://localhost:3000/icons/typescript?color=%23FF0000"
-curl "http://localhost:3000/icons/html?foreground=%230088CC"
-curl "http://localhost:3000/icons/react?color=%230088CC&foreground=%23FF0000"
-curl "http://localhost:3000/icons/react?glow=true"
-curl "http://localhost:3000/icons/typescript?glow=true&glowColor=%23FF00FF"
-curl "http://localhost:3000/icons/github?glow=true&glowColor=blue"
-curl "http://localhost:3000/icons/react?color=%230088CC&glow=true&glowColor=%2300FF00"
-```
-
----
-
-## GET /icons/demo - Icons Gallery Page
-
-Returns an interactive HTML page previewing all available icons.
-
-### Endpoint
-```
-GET /icons/demo
-```
-
-### Required Parameters
-
-None.
-
-### Optional Parameters
-
-None.
-
-### Response
-**Content-Type:** `text/html`
-
-### Example
-
-```bash
-curl "http://localhost:3000/icons/demo"
-```
-
-Open in browser:
-
-```text
-http://localhost:3000/icons/demo
-```
-
----
-
-## General Notes
-
-### Error Handling
-All core routes return appropriate HTTP status codes:
-- `200` - Success
-- `400` - Missing or invalid required parameters
-- `404` - User not found
-- `500` - Server error (check `/cache/health`)
-
-### Response Headers
-```
-Content-Type: image/svg+xml (or configured format)
-Cache-Control: public, max-age=3600
-```
-
-### Performance Optimization
-- Requests are automatically deduplicated while processing
-- Multiple identical requests are coalesced into one API call
-- Cache middleware handles response caching transparently
-- WebP/PNG conversion uses sharp library for optimal compression
-
-### Rate Limiting Notes
-- GitHub API calls are optimized to minimize quotas
-- All responses are cached to prevent duplicate API calls
-- Cache warming can pre-populate frequently accessed stats
-
-### Access from Different Platforms
-
-#### GitHub Profile README
-```markdown
-
-```
-
-#### LinkedIn Profile
-Embed as image URL in profile
-
-#### Portfolio Website
-```html
-
-```
-
-#### Discord
-Share as image URL in messages
-
----
-
-## Troubleshooting
-
-### No Data Returned
-1. Verify the username exists on GitHub
-2. Check if the account is public
-3. Verify GitHub token has sufficient permissions
-
-### Cache Issues
-Check cache status:
-```bash
-curl "http://localhost:3000/cache/health"
-```
-
-### Styling Issues
-- Ensure theme name is valid
-- Verify hex colors are properly URL-encoded
-- Test with basic theme first
-
-### Timeout Issues
-These routes may timeout for users with:
-- Very large number of repositories
-- Complex contribution history
-- High API latency
-
-Consider increasing cache TTL or investigating Redis connection.
-
----
-
-## See Also
-- [User Badge Routes](./USER_BADGES.md) - Individual badge endpoints
-- [Project Badge Routes](./PROJECT_BADGES.md) - Repository-specific badges
-- [Cache Monitoring Guide](./CACHE_MONITORING.md) - Health and performance checks
-- [Route-by-Route Demos](../example/README.md) - Quick visual examples for each route
diff --git a/docs/how-to/DEVELOPMENT.md b/docs/how-to/DEVELOPMENT.md
deleted file mode 100644
index 8877f8a..0000000
--- a/docs/how-to/DEVELOPMENT.md
+++ /dev/null
@@ -1,111 +0,0 @@
-# Development Guide
-
-This guide contains all local setup, environment, database, and run commands for the project.
-
-## Prerequisites
-
-- Node.js 18+ (LTS recommended)
-- GitHub Personal Access Token (recommended for higher rate limits)
-- Redis (optional, for multi-tier caching)
-- SQLite (bundled, no setup needed)
-
-## Setup
-
-```bash
-npm install
-```
-
-Create a `.env` file:
-
-```env
-# Required
-GITHUB_TOKEN=your_github_personal_access_token
-
-# Server
-PORT=3000
-APP_ENV=development # development | production
-HOST=localhost
-
-# Cache (optional)
-CACHE_DURATION=7200000 # 2 hours in ms
-GITHUB_CACHE_TTL=1800000 # 30 min in ms
-WARMUP_USERNAME=pphatdev # Pre-warm cache on startup
-
-# Redis (optional - falls back to in-memory cache if not set)
-REDIS_ENABLED=true
-REDIS_URL=redis://localhost:6379
-# Or use individual settings:
-# REDIS_HOST=localhost
-# REDIS_PORT=6379
-# REDIS_USERNAME=default
-# REDIS_PASSWORD=your_password
-# REDIS_DB=0
-# REDIS_TLS=false
-
-# Database
-DATABASE_URL=./data/stats.db # SQLite database path
-
-# Monitoring
-ENABLE_METRICS=true
-DEBUG=false
-```
-
-## Database Setup
-
-The project uses Drizzle ORM with SQLite. Run migrations:
-
-```bash
-# Generate migration files
-npm run db:generate
-
-# Apply migrations
-npm run db:migrate
-
-# Or push schema directly (development)
-npm run db:push
-
-# Open Drizzle Studio (database GUI)
-npm run db:studio
-```
-
-## Running
-
-Development mode (with hot reload):
-
-```bash
-npm run dev
-```
-
-Build and run:
-
-```bash
-npm run build
-npm start
-```
-
-Production cluster mode (multi-core):
-
-```bash
-npm run build
-npm run start:cluster # Uses all CPU cores
-npm run start:production # Production mode with all optimizations
-
-# Specify worker count
-WORKERS=4 npm run start:cluster
-```
-
-## Available Scripts
-
-| Script | Description |
-|--------|-------------|
-| `npm run dev` | Start development server with hot reload |
-| `npm run build` | Compile TypeScript to JavaScript |
-| `npm start` | Start single-process server |
-| `npm run start:cluster` | Start multi-core cluster server |
-| `npm run start:production` | Production mode with all optimizations |
-| `npm run db:generate` | Generate Drizzle migration files |
-| `npm run db:migrate` | Apply database migrations |
-| `npm run db:push` | Push schema changes directly |
-| `npm run db:studio` | Open Drizzle Studio GUI |
-| `npm run cache:clear` | Clear Redis cache |
-| `npm test` | Run tests |
diff --git a/docs/how-to/PROJECT_BADGES.md b/docs/how-to/PROJECT_BADGES.md
deleted file mode 100644
index dabd40b..0000000
--- a/docs/how-to/PROJECT_BADGES.md
+++ /dev/null
@@ -1,561 +0,0 @@
-# Project Badge Routes
-
-These routes provide repository-specific badges that display metrics about a GitHub project. Each badge can be customized with themes and colors for embedding in project READMEs or documentation.
-
-## Overview
-
-Project badges are lightweight SVG components showing specific metrics about a GitHub repository. They're ideal for:
-- Project README files
-- Repository documentation
-- Dependency/contribution tracking
-- Project showcases
-- Portfolio sites
-
-All project badges require a `repo` parameter in the format: `owner/repository`
-
----
-
-## Table of Contents
-- [GET /project/visitors](#get-projectvisitors---unique-visitors)
-- [GET /project/stars](#get-projectstars---repository-stars)
-- [GET /project/forks](#get-projectforks---repository-forks)
-- [GET /project/watchers](#get-projectwatchers---watchers)
-- [GET /project/issues](#get-projectissues---open-issues)
-- [GET /project/prs](#get-projectprs---pull-requests)
-- [GET /project/contributors](#get-projectcontributors---contributor-count)
-- [GET /project/size](#get-projectsize---repository-size)
-
----
-
-## Common Parameters
-
-All project badge endpoints support these optional parameters:
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `theme` | string | `default` | Badge color theme |
-| `customLabel` | string | - | Custom label text (replaces default) |
-| `labelColor` | string | - | Background color for label (hex) |
-| `labelBackground` | string | - | Alternative label background (hex) |
-| `valueColor` | string | - | Value text color (hex) |
-| `valueBackground` | string | - | Value background color (hex) |
-
----
-
-## GET /project/visitors - Visitors
-
-Counts visitors for a repository badge endpoint once per same IP every 5 minutes. Refreshes within the same 5-minute window from the same IP do not increment.
-
-### Endpoint
-```
-GET /project/visitors
-```
-
-### Required Parameters
-
-| Parameter | Type | Format | Description |
-|-----------|------|--------|-------------|
-| `repo` | string | `owner/repository` | Repository identifier |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing total unique visits recorded for this repository badge.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/project/visitors?repo=pphatdev/github-stats"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/project/visitors?repo=pphatdev/github-stats&theme=tokyo"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /project/stars - Repository Stars
-
-Displays the total number of stars (favorites) the repository has received.
-
-### Endpoint
-```
-GET /project/stars
-```
-
-### Required Parameters
-
-| Parameter | Type | Format | Description |
-|-----------|------|--------|-------------|
-| `repo` | string | `owner/repository` | Repository identifier |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing the star count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/project/stars?repo=pphatdev/github-stats"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/project/stars?repo=pphatdev/github-stats&theme=tokyo"
-```
-
-#### Custom Styling
-```bash
-curl "http://localhost:3000/project/stars?repo=pphatdev/github-stats&customLabel=Favorites&valueColor=%23ffff00"
-```
-
-#### With All Custom Colors
-```bash
-curl "http://localhost:3000/project/stars?repo=pphatdev/github-stats&labelColor=%23000000&valueColor=%23ffffff&valueBackground=%231f2937"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
-### HTML Embedding
-```html
-
-```
-
-### Caching
-- **TTL:** 1 hour
-- **Key:** `project:stars:{repo}`
-- **Layer:** Redis persistent + In-Memory
-
----
-
-## GET /project/forks - Repository Forks
-
-Shows the total number of times the repository has been forked.
-
-### Endpoint
-```
-GET /project/forks
-```
-
-### Required Parameters
-
-| Parameter | Type | Format | Description |
-|-----------|------|--------|-------------|
-| `repo` | string | `owner/repository` | Repository identifier |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying the fork count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/project/forks?repo=pphatdev/github-stats"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/project/forks?repo=pphatdev/github-stats&theme=dracula"
-```
-
-#### Custom Label
-```bash
-curl "http://localhost:3000/project/forks?repo=pphatdev/github-stats&customLabel=Copies"
-```
-
-### Markdown Embedding
-```markdown
-[](https://github.com/pphatdev/github-stats/network/members)
-```
-
----
-
-## GET /project/watchers - Watchers
-
-Displays the number of users watching the repository for updates.
-
-### Endpoint
-```
-GET /project/watchers
-```
-
-### Required Parameters
-
-| Parameter | Type | Format | Description |
-|-----------|------|--------|-------------|
-| `repo` | string | `owner/repository` | Repository identifier |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing watcher count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/project/watchers?repo=pphatdev/github-stats"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/project/watchers?repo=pphatdev/github-stats&theme=nord"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /project/issues - Open Issues
-
-Shows the total number of open issues in the repository.
-
-### Endpoint
-```
-GET /project/issues
-```
-
-### Required Parameters
-
-| Parameter | Type | Format | Description |
-|-----------|------|--------|-------------|
-| `repo` | string | `owner/repository` | Repository identifier |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying open issue count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/project/issues?repo=pphatdev/github-stats"
-```
-
-#### Custom Styling
-```bash
-curl "http://localhost:3000/project/issues?repo=pphatdev/github-stats&theme=tokyo&customLabel=Open%20Bugs"
-```
-
-#### Link to Issues
-```bash
-# Create a clickable badge in markdown
-[](https://github.com/pphatdev/github-stats/issues)
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /project/prs - Pull Requests
-
-Displays the total number of pull requests in the repository.
-
-### Endpoint
-```
-GET /project/prs
-```
-
-### Required Parameters
-
-| Parameter | Type | Format | Description |
-|-----------|------|--------|-------------|
-| `repo` | string | `owner/repository` | Repository identifier |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing pull request count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/project/prs?repo=pphatdev/github-stats"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/project/prs?repo=pphatdev/github-stats&theme=dracula"
-```
-
-#### Custom Label
-```bash
-curl "http://localhost:3000/project/prs?repo=pphatdev/github-stats&customLabel=Contributions"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /project/contributors - Contributor Count
-
-Shows the total number of people who have contributed to the repository.
-
-### Endpoint
-```
-GET /project/contributors
-```
-
-### Required Parameters
-
-| Parameter | Type | Format | Description |
-|-----------|------|--------|-------------|
-| `repo` | string | `owner/repository` | Repository identifier |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying contributor count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/project/contributors?repo=pphatdev/github-stats"
-```
-
-#### With Theme and Colors
-```bash
-curl "http://localhost:3000/project/contributors?repo=pphatdev/github-stats&theme=tokyo&valueColor=%23ff0000"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
-### Notes
-- Counts all contributors including bots
-- Includes contributors to all branches
-- Updates with each new commit
-
----
-
-## GET /project/size - Repository Size
-
-Displays the size of the repository on disk.
-
-### Endpoint
-```
-GET /project/size
-```
-
-### Required Parameters
-
-| Parameter | Type | Format | Description |
-|-----------|------|--------|-------------|
-| `repo` | string | `owner/repository` | Repository identifier |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing repository size in KB/MB/GB.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/project/size?repo=pphatdev/github-stats"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/project/size?repo=pphatdev/github-stats&theme=dracula"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
-### Notes
-- Shows total size of repository including all history
-- Larger repositories will show in MB or GB
-- Updates after significant changes
-
----
-
-## Theme Examples
-
-### Available Themes
-- `default` - Standard theme
-- `tokyo` - Tokyo Night (dark, purple/pink)
-- `dracula` - Dracula (dark, purple/red)
-- `nord` - Nord (cool, blue-based)
-- `solarized` - Solarized (warm, orange/red)
-- And many more...
-
-### Using Themes
-```bash
-# Tokyo theme
-curl "http://localhost:3000/project/stars?repo=pphatdev/github-stats&theme=tokyo"
-
-# Dracula theme
-curl "http://localhost:3000/project/forks?repo=pphatdev/github-stats&theme=dracula"
-
-# Nord theme
-curl "http://localhost:3000/project/contributors?repo=pphatdev/github-stats&theme=nord"
-```
-
----
-
-## Complete Project README Example
-
-Include multiple project badges in your README:
-
-```markdown
-# My Awesome Project
-
-
-
-
-
-
-## About This Project
-
-This is an amazing project that generates GitHub statistics.
-
-### Statistics
-
-| Metric | Count |
-|--------|-------|
-|  | Stars |
-|  | Contributors |
-|  | Repository Size |
-
-## Getting Started
-
-See documentation for more details...
-```
-
----
-
-## Advanced Customization
-
-### Color Hex Codes
-Popular colors for badges:
-
-| Color | Hex | Usage |
-|-------|-----|-------|
-| Black | `%23000000` | Labels, text |
-| White | `%23ffffff` | Text, contrast |
-| Blue | `%233b82f6` | Primary color |
-| Green | `%2310b981` | Success, active |
-| Red | `%23ef4444` | Warnings, errors |
-| Purple | `%238b5cf6` | Accent, special |
-| Yellow | `%23fbbf24` | Warnings, highlights |
-
-### Custom Color Example
-```bash
-curl "http://localhost:3000/project/stars?repo=pphatdev/github-stats&labelColor=%23000000&valueColor=%23ffffff&valueBackground=%233b82f6"
-```
-
-URL-encoded color format: Use `%23` instead of `#`
-
----
-
-## Caching
-
-All project badge endpoints use intelligent caching:
-- **Redis Persistent Cache:** Survives server restarts
-- **In-Memory Cache:** Fast request deduplication
-- **TTL:** 1 hour by default
-- **Database:** SQLite for persistent stats
-
-Check cache status:
-```bash
-curl "http://localhost:3000/cache/health"
-```
-
----
-
-## Error Handling
-
-Project badge endpoints return:
-- `200` - Success
-- `400` - Missing or invalid repo parameter
-- `404` - Repository not found on GitHub
-- `500` - Server error
-
-Error responses include descriptive error messages.
-
-### Common Issues
-
-#### "Repository not found"
-- Verify the repo format: `owner/repository`
-- Check that the repo is public
-- Ensure the repository actually exists on GitHub
-
-#### "Invalid parameter"
-- Check your URL encoding
-- Verify all required parameters are present
-- Ensure hex colors start with `%23` in URLs
-
----
-
-## Best Practices
-
-### 1. Use Consistent Themes
-Apply the same theme to all badges for visual consistency:
-```bash
-# All badges with Tokyo theme
-curl "http://localhost:3000/project/stars?repo=owner/repo&theme=tokyo"
-curl "http://localhost:3000/project/forks?repo=owner/repo&theme=tokyo"
-```
-
-### 2. Link Badges to Relevant Pages
-Make badges clickable by wrapping in markdown links:
-```markdown
-[](https://github.com/owner/repo)
-[](https://github.com/owner/repo/issues)
-[](https://github.com/owner/repo/pulls)
-```
-
-### 3. Group Related Badges
-Organize badges logically in your README:
-```markdown
-## Project Stats
-
-  
-
-## Community
-
- 
-```
-
-### 4. Performance Considerations
-- Badges are cached for 1 hour
-- Use HTTPS for production deployments
-- Consider CDN caching for high traffic
-
----
-
-## See Also
-- [Core Statistics Routes](./CORE_ROUTES.md) - Full stats cards
-- [User Badge Routes](./USER_BADGES.md) - User profile badges
-- [Cache Monitoring Guide](./CACHE_MONITORING.md) - Performance monitoring
diff --git a/docs/how-to/README.md b/docs/how-to/README.md
deleted file mode 100644
index d2a7e3d..0000000
--- a/docs/how-to/README.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# GitHub Stats API Documentation
-
-This folder contains comprehensive guides on how to use each API route available in the GitHub Stats application.
-
-## Quick Navigation
-
-### ๐ Core Routes
-- **[Core Statistics Routes](./CORE_ROUTES.md)** - Main stats, languages, and graph visualization endpoints
-
-### ๐ค User Badges
-- **[User Badge Routes](./USER_BADGES.md)** - Personal GitHub statistics badges (visitors, repos, followers, etc.)
-
-### ๐ Project Badges
-- **[Project Badge Routes](./PROJECT_BADGES.md)** - Repository-specific badges (stars, forks, issues, etc.)
-
-### ๐จ Icons & Visual Assets
-- **[Release Icons Documentation](./RELEASE_ICONS.md)** - Using icons in releases, changelogs, and READMEs
-
-### ๐ง Monitoring & Health
-- **[Cache Monitoring Guide](./CACHE_MONITORING.md)** - Health checks and cache statistics endpoints
-
-### ๐ Development
-- **[Development Guide](./DEVELOPMENT.md)** - Local setup, environment variables, database, and run scripts
-
-### ๐งช Route-by-Route Demos
-- **[Route Demo Index](../example/README.md)** - One file per route with demo examples for each option
-
-## Overview
-
-The GitHub Stats API provides multiple endpoints for:
-
-1. **Statistics Rendering** - Generate detailed stats cards and visualizations
-2. **Icon Delivery** - List and serve reusable SVG icons with optional recoloring
-3. **User Badges** - Create individual badge components for specific metrics
-4. **Project Badges** - Repository-specific metric badges
-5. **Cache Management** - Monitor cache health and performance
-
-## Base URL
-
-```
-http://localhost:3000 (default development)
-```
-
-## Common Query Parameters
-
-All endpoints support optional styling parameters:
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `theme` | string | `default` | Badge color theme (tokyo, dracula, nord, etc.) |
-| `customLabel` | string | - | Custom label text for badges |
-| `labelColor` | string | - | Custom color for label background |
-| `iconColor` | string | - | Custom color for icons |
-| `valueColor` | string | - | Custom color for values |
-
-## Response Formats
-
-Most endpoints return SVG by default. Some support format conversion:
-
-- **SVG** (default) - `?format=svg`
-- **WebP** - `?format=webp`
-- **PNG** - `?format=png` (some routes only)
-
-## Caching
-
-All endpoints include intelligent caching:
-
-- **Redis Persistent Cache** - Survives server restarts (1min - 2hrs TTL)
-- **In-Memory Cache** - Fast local caching layer (600-3600s TTL)
-- **Database Cache** - SQLite persistence (2hr max)
-
-## Rate Limiting
-
-- Requests are deduplicated at the service level
-- Cache middleware handles request coalescing
-- GitHub API calls are optimized to minimize quota usage
-
-## Authentication
-
-Most endpoints use the configured GitHub token for API calls. Some public endpoints may work without authentication with reduced rate limits.
-
-## Examples
-
-### Get User Statistics
-```bash
-curl "http://localhost:3000/stats?username=pphatdev&theme=tokyo"
-```
-
-### Get User Badge
-```bash
-curl "http://localhost:3000/badge/followers?username=pphatdev&theme=dracula"
-```
-
-### Get Project Statistics
-```bash
-curl "http://localhost:3000/project/stars?repo=pphatdev/github-stats"
-```
-
-### Check Cache Health
-```bash
-curl "http://localhost:3000/cache/health"
-```
-
-## Embedded Usage
-
-All SVG responses can be embedded in:
-
-- **Markdown** - ``
-- **HTML** - ` `
-- **README badges** - Works great in GitHub profiles and project READMEs
-
-## Troubleshooting
-
-If an endpoint returns an error:
-
-1. Check cache status: `/cache/health`
-2. Verify correct required parameters
-3. View cache statistics: `/cache/stats`
-4. Check that GitHub token is configured
-5. Ensure username/repo format is correct (owner/repo for projects)
-
-## Support
-
-For issues or feature requests, refer to the main project repository documentation.
diff --git a/docs/how-to/USER_BADGES.md b/docs/how-to/USER_BADGES.md
deleted file mode 100644
index 2c908f8..0000000
--- a/docs/how-to/USER_BADGES.md
+++ /dev/null
@@ -1,591 +0,0 @@
-# User Badge Routes
-
-These routes provide individual badge components that display specific user metrics. Each badge can be customized with different themes and colors for embedding in profiles, READMEs, or documentation.
-
-## Overview
-
-User badges are lightweight SVG components that show a single metric about a GitHub user. They're perfect for:
-- GitHub profile README sections
-- Portfolio websites
-- Documentation
-- Personal project pages
-
-All user badges require a `username` parameter.
-
----
-
-## Table of Contents
-- [GET /badge/visitors](#get-badgevisitors---visitor-count)
-- [GET /badge/repositories](#get-badgerepositories---public-repositories)
-- [GET /badge/organization](#get-badgeorganization---organization)
-- [GET /badge/languages](#get-badgelanguages---language-count)
-- [GET /badge/followers](#get-badgefollowers---follower-count)
-- [GET /badge/total-stars](#get-badgetotal-stars---total-stars-earned)
-- [GET /badge/total-contributors](#get-badgetotal-contributors---total-contributors)
-- [GET /badge/total-commits](#get-badgetotal-commits---total-commits)
-- [GET /badge/total-code-reviews](#get-badgetotal-code-reviews---code-reviews)
-- [GET /badge/total-issues](#get-badgetotal-issues---github-issues)
-- [GET /badge/total-pull-requests](#get-badgetotal-pull-requests---pull-requests)
-- [GET /badge/total-joined-years](#get-badgetotal-joined-years---years-on-github)
-
----
-
-## Common Parameters
-
-All badge endpoints support these optional parameters:
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `theme` | string | `default` | Badge color theme |
-| `customLabel` | string | - | Custom label text (replaces default) |
-| `labelColor` | string | - | Background color for label (hex) |
-| `labelBackground` | string | - | Alternative label background (hex) |
-| `iconColor` | string | - | Icon color (hex) |
-| `valueColor` | string | - | Value text color (hex) |
-| `valueBackground` | string | - | Value background color (hex) |
-| `hideFrame` | boolean | `false` | Hide the frame/container |
-| `hideIcon` | boolean | `true` | Hide the badge icon |
-
----
-
-## GET /badge/visitors - Visitor Count
-
-Displays the total number of unique visitors to the user's stats badge.
-
-### Endpoint
-```
-GET /badge/visitors
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing visitor count to this specific badge endpoint.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/visitors?username=pphatdev"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/badge/visitors?username=pphatdev&theme=tokyo"
-```
-
-#### Custom Styling
-```bash
-curl "http://localhost:3000/badge/visitors?username=pphatdev&customLabel=Profile%20Visits&iconColor=%23ff0000"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
-### Caching
-- **TTL:** Varies by request frequency
-- **Key:** `badge:visitors:{username}`
-- **Tracked:** In database for analytics
-
----
-
-## GET /badge/repositories - Public Repositories
-
-Shows the total number of public repositories owned by the user.
-
-### Endpoint
-```
-GET /badge/repositories
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying total public repository count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/repositories?username=pphatdev"
-```
-
-#### With Custom Colors
-```bash
-curl "http://localhost:3000/badge/repositories?username=pphatdev&labelColor=%23000000&valueColor=%23ffffff"
-```
-
-#### With Theme and Icon Visible
-```bash
-curl "http://localhost:3000/badge/repositories?username=pphatdev&theme=dracula&hideIcon=false"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /badge/organization - Organization
-
-Displays the user's primary organization affiliation.
-
-### Endpoint
-```
-GET /badge/organization
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing the user's organization name (if available).
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/organization?username=pphatdev"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/badge/organization?username=pphatdev&theme=nord"
-```
-
-### Notes
-- Returns the primary organization the user is a member of
-- If no organization, displays "None" or similar
-- Organization must be public to appear
-
----
-
-## GET /badge/languages - Language Count
-
-Shows the number of unique programming languages used in the user's repositories.
-
-### Endpoint
-```
-GET /badge/languages
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying unique language count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/languages?username=pphatdev"
-```
-
-#### Custom Label
-```bash
-curl "http://localhost:3000/badge/languages?username=pphatdev&customLabel=Familiar%20Languages"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /badge/followers - Follower Count
-
-Displays the user's current follower count.
-
-### Endpoint
-```
-GET /badge/followers
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing follower count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/followers?username=pphatdev"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/badge/followers?username=pphatdev&theme=tokyo"
-```
-
-#### Custom Styling
-```bash
-curl "http://localhost:3000/badge/followers?username=pphatdev&customLabel=GitHub%20Followers&valueColor=%23ffffff"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /badge/total-stars - Total Stars Earned
-
-Shows the total number of stars earned across all user repositories.
-
-### Endpoint
-```
-GET /badge/total-stars
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying cumulative star count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/total-stars?username=pphatdev"
-```
-
-#### With Theme and Colors
-```bash
-curl "http://localhost:3000/badge/total-stars?username=pphatdev&theme=dracula&valueColor=%23ffff00"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /badge/total-contributors - Total Contributors
-
-Shows the total number of people who have contributed to the user's repositories.
-
-### Endpoint
-```
-GET /badge/total-contributors
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying total contributor count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/total-contributors?username=pphatdev"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/badge/total-contributors?username=pphatdev&theme=nord"
-```
-
----
-
-## GET /badge/total-commits - Total Commits
-
-Displays the user's total commits across all repositories.
-
-### Endpoint
-```
-GET /badge/total-commits
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing cumulative commit count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/total-commits?username=pphatdev"
-```
-
-#### Custom Label
-```bash
-curl "http://localhost:3000/badge/total-commits?username=pphatdev&customLabel=My%20Commits"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /badge/total-code-reviews - Code Reviews
-
-Shows the total number of code review comments made by the user.
-
-### Endpoint
-```
-GET /badge/total-code-reviews
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying code review count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/total-code-reviews?username=pphatdev"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/badge/total-code-reviews?username=pphatdev&theme=tokyo"
-```
-
----
-
-## GET /badge/total-issues - GitHub Issues
-
-Displays the total number of issues created by the user.
-
-### Endpoint
-```
-GET /badge/total-issues
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing issue count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/total-issues?username=pphatdev"
-```
-
----
-
-## GET /badge/total-pull-requests - Pull Requests
-
-Shows the total number of pull requests created by the user.
-
-### Endpoint
-```
-GET /badge/total-pull-requests
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge displaying pull request count.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/total-pull-requests?username=pphatdev"
-```
-
-#### With Theme
-```bash
-curl "http://localhost:3000/badge/total-pull-requests?username=pphatdev&theme=dracula&hideIcon=false"
-```
-
-### Markdown Embedding
-```markdown
-
-```
-
----
-
-## GET /badge/total-joined-years - Years on GitHub
-
-Displays how many years the user has been active on GitHub.
-
-### Endpoint
-```
-GET /badge/total-joined-years
-```
-
-### Required Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `username` | string | GitHub username |
-
-### Response
-**Content-Type:** `image/svg+xml`
-
-Badge showing years since account creation.
-
-### Examples
-
-#### Basic Usage
-```bash
-curl "http://localhost:3000/badge/total-joined-years?username=pphatdev"
-```
-
-#### Custom Label
-```bash
-curl "http://localhost:3000/badge/total-joined-years?username=pphatdev&customLabel=GitHub%20Member%20For"
-```
-
----
-
-## Theme Examples
-
-### Available Themes
-All badges support multiple themes. Common themes include:
-- `default` - Standard theme
-- `tokyo` - Tokyo Night (dark, purple/pink)
-- `dracula` - Dracula (dark, purple/red)
-- `nord` - Nord (cool, blue-based)
-- `solarized` - Solarized (warm, orange/red)
-- And more...
-
-### Theme Usage
-```bash
-# Tokyo theme
-curl "http://localhost:3000/badge/followers?username=pphatdev&theme=tokyo"
-
-# Dracula theme
-curl "http://localhost:3000/badge/total-stars?username=pphatdev&theme=dracula"
-```
-
----
-
-## Complete Profile Example
-
-Create a profile with multiple badges:
-
-```markdown
-# Welcome to My Profile! ๐
-
-
-
-
-
-## My Statistics
-
-
-
-## Quick Stats
-
--  Public Repositories
--  Programming Languages
--  Total Commits
--  Pull Requests
-```
-
----
-
-## Caching
-
-All badge endpoints include intelligent caching:
-- **Redis Layer:** Persistent cache (survives restarts)
-- **In-Memory Layer:** Fast request deduplication
-- **TTL:** Typically 1-2 hours depending on metric
-- **Database:** SQLite persistence for historical tracking
-
-For cache status, check:
-```bash
-curl "http://localhost:3000/cache/health"
-```
-
----
-
-## Error Handling
-
-Badge endpoints return:
-- `200` - Success
-- `400` - Missing or invalid username
-- `404` - User not found on GitHub
-- `500` - Server error
-
-All errors include error information in the response.
-
----
-
-## See Also
-- [Core Statistics Routes](./CORE_ROUTES.md) - Full stats cards
-- [Project Badge Routes](./PROJECT_BADGES.md) - Repository badges
-- [Cache Monitoring Guide](./CACHE_MONITORING.md) - Performance and health checks
diff --git a/docs/structures/01_OVERVIEW_AND_FLOWS.md b/docs/structures/01_OVERVIEW_AND_FLOWS.md
new file mode 100644
index 0000000..c7c315f
--- /dev/null
+++ b/docs/structures/01_OVERVIEW_AND_FLOWS.md
@@ -0,0 +1,128 @@
+# Project Structure: Overview and Flows
+
+## Version
+
+Current Version: **2.1.1**
+
+## System Architecture Flow
+
+### Overall Request Flow
+
+```mermaid
+flowchart LR
+ Client[Client Request] --> LB[Load Balancer]
+ LB --> W1[Worker 1]
+ LB --> W2[Worker 2]
+ LB --> W3[Worker N]
+
+ W1 --> MW[Middleware Layer]
+ W2 --> MW
+ W3 --> MW
+
+ MW --> Router[Router]
+ Router --> Module[Module Controller]
+ Module --> Service[Service Layer]
+
+ Service --> Cache{Cache Hit?}
+ Cache -->|Yes| Return[Return Cached SVG]
+ Cache -->|No| Data[Data Layer]
+
+ Data --> MEM[(Memory Cache)]
+ Data --> REDIS[(Redis Cache)]
+ Data --> DB[(SQLite DB)]
+ Data --> API[GitHub API]
+
+ MEM --> Render[SVG Renderer]
+ REDIS --> Render
+ DB --> Render
+ API --> Render
+
+ Render --> Store[Store in Cache]
+ Store --> Return
+ Return --> Client
+```
+
+### Module Architecture Flow
+
+```mermaid
+flowchart TD
+ Request[HTTP Request] --> Routes[Route Handler]
+ Routes --> Validation[Input Validation]
+ Validation --> Controller[Controller]
+
+ Controller --> Service[Service Layer]
+
+ Service --> Cache{Check Cache}
+ Cache -->|Hit| Response[Format Response]
+ Cache -->|Miss| External[External APIs]
+
+ External --> GitHub[GitHub API]
+ External --> Database[(Database)]
+ External --> FileSystem[File System]
+
+ GitHub --> Transform[Data Transform]
+ Database --> Transform
+ FileSystem --> Transform
+
+ Transform --> Render[Render Component]
+ Render --> SaveCache[Save to Cache]
+ SaveCache --> Response
+
+ Response --> Controller
+ Controller --> SVG[SVG/PNG/WebP]
+ SVG --> Client[Client Response]
+```
+
+### Caching Strategy Flow
+
+```mermaid
+flowchart TD
+ Request[Incoming Request] --> L1{L1: Memory Cache}
+
+ L1 -->|Hit - TTL Valid| Return1[Return Immediately]
+ L1 -->|Miss| L2{L2: Redis Cache}
+
+ L2 -->|Hit - TTL Valid| Store1[Store in L1]
+ Store1 --> Return2[Return Data]
+
+ L2 -->|Miss| L3{L3: Database}
+
+ L3 -->|Hit - Fresh Data| Store2[Store in Redis + Memory]
+ Store2 --> Return3[Return Data]
+
+ L3 -->|Miss/Stale| API[GitHub API Call]
+
+ API --> Process[Process & Transform]
+ Process --> StoreAll[Store in All Caches]
+ StoreAll --> DB[(Update Database)]
+ StoreAll --> Redis[(Update Redis)]
+ StoreAll --> Mem[(Update Memory)]
+
+ DB --> Return4[Return Fresh Data]
+ Redis --> Return4
+ Mem --> Return4
+
+ Return1 --> Client[Client]
+ Return2 --> Client
+ Return3 --> Client
+ Return4 --> Client
+```
+
+## Architecture Overview
+
+### Technology Stack
+
+- **Runtime**: Node.js 18+
+- **Language**: TypeScript
+- **Framework**: Express.js
+- **Database**: SQLite with Drizzle ORM
+- **Cache**: Redis (optional) + in-memory
+- **Process Management**: PM2 / Native cluster module
+
+### Data Flow
+
+```text
+Request -> Middleware -> Controller -> Service -> Cache/DB/GitHub API
+ ->
+Response <- Renderer <- Transform <- Process <- Data
+```
diff --git a/docs/structures/02_DIRECTORY_MAP.md b/docs/structures/02_DIRECTORY_MAP.md
new file mode 100644
index 0000000..f2de4ed
--- /dev/null
+++ b/docs/structures/02_DIRECTORY_MAP.md
@@ -0,0 +1,122 @@
+# Project Structure: Directory Map
+
+## Root Directory
+
+```text
+stats.pphat.top/
+|- src/ # Source code
+|- public/ # Static assets
+|- docs/ # Documentation
+|- data/ # Database files
+|- drizzle/ # Database migrations
+|- scripts/ # Utility scripts
+|- tests/ # Test files
+|- package.json # Project dependencies and scripts
+|- tsconfig.json # TypeScript configuration
+|- drizzle.config.ts # Drizzle ORM configuration
+|- wrangler.toml # Cloudflare Workers configuration
+|- ecosystem.config.cjs # PM2 configuration
+`- README.md # Main documentation
+```
+
+## Source Code (src)
+
+### Entry Points
+
+```text
+src/
+|- index.ts
+|- server.ts
+|- server-cluster.ts
+|- cluster.ts
+|- worker.ts
+|- app.ts
+`- types.ts
+```
+
+### Configuration (src/config)
+
+```text
+src/config/
+|- index.ts
+|- env.ts
+|- db.ts
+|- logger.ts
+`- swagger.ts
+```
+
+### Database (src/db)
+
+```text
+src/db/
+|- index.ts
+|- pool.ts
+`- schema.ts
+```
+
+### Modules (src/modules)
+
+- Each module includes: index.ts, *.controller.ts, *.routes.ts, *.service.ts, *.types.ts
+- Current module folders: badges, graphs, health, icons, languages, stats
+
+### Routes, Services, Shared, Views
+
+```text
+src/routes/docs.routes.ts
+src/services/badge-cache.service.ts
+src/services/base.service.ts
+src/shared/
+src/views/icons-demo.view.tsx
+```
+
+## Public Assets (public)
+
+```text
+public/
+|- assets/icons/
+|- css/main.css
+|- css/icons-demo.css
+|- fonts/
+|- user/pphatdev/
+|- icons-demo.js
+`- sitemap.xml
+```
+
+## Documentation (docs)
+
+Path-by-path breakdown:
+
+| Path | Type | Purpose |
+|------|------|---------|
+| docs/collections/ | Directory | API collections and integration assets |
+| docs/collections/postman_collection.json | File | Postman collection for API testing |
+| docs/example/ | Directory | Endpoint usage examples |
+| docs/example/README.md | File | Overview for example documents |
+| docs/example/badge-collection.md | File | Badge collection usage examples |
+| docs/example/badge-user.md | File | User badge usage examples |
+| docs/example/graph.md | File | Graph endpoint usage examples |
+| docs/example/icon-collection.md | File | Icon collection usage examples |
+| docs/example/icons.md | File | Icon endpoint usage examples |
+| docs/example/languages.md | File | Language stats usage examples |
+| docs/example/project.md | File | Project-related usage examples |
+| docs/example/stats.md | File | Stats endpoint usage examples |
+| docs/features/ | Directory | Feature-specific documentation |
+| docs/features/badges.md | File | Badge feature details |
+| docs/RELEASE/ | Directory | Release notes and changelog docs |
+| docs/RELEASE/RELEASE_ICONS.md | File | Icons release notes |
+| docs/RELEASE/RELEASE_v2.0.3.md | File | Version 2.0.3 release notes |
+| docs/STRUCTURE/ | Directory | Repository and architecture structure docs |
+| docs/STRUCTURE/PROJECT_STRUCTURE.md | File | Structure index and navigation |
+
+## Database, Scripts, Tests, and Config
+
+```text
+drizzle/
+scripts/
+tests/
+package.json
+tsconfig.json
+drizzle.config.ts
+wrangler.toml
+ecosystem.config.cjs
+```
diff --git a/docs/structures/03_PRACTICES_AND_WORKFLOW.md b/docs/structures/03_PRACTICES_AND_WORKFLOW.md
new file mode 100644
index 0000000..ca99a63
--- /dev/null
+++ b/docs/structures/03_PRACTICES_AND_WORKFLOW.md
@@ -0,0 +1,35 @@
+# Project Structure: Best Practices and Workflow
+
+## Best Practices
+
+### Module Creation
+
+1. Create folder in src/modules/{name}/
+2. Add required files: controller, routes, service, types, index
+3. Export from index.ts
+4. Register routes in main app.ts
+5. Add documentation in docs/how-to/
+6. Add examples in docs/example/
+
+### File Naming
+
+- Use kebab-case for files: badge-renderer.ts
+- Use PascalCase for classes: class BadgeRenderer
+- Use camelCase for functions: function renderBadge()
+- Module files: {module-name}.{type}.ts
+
+## Development Workflow
+
+1. Setup: install dependencies and configure environment
+2. Development: use npm run dev
+3. Database: run npm run db:migrate
+4. Testing: run npm test
+5. Build: run npm run build
+6. Deploy: run npm start (clustered by default) or set WORKERS to pin the worker count
+
+## Related Documentation
+
+- [Development Guide](../how-to/DEVELOPMENT.md)
+- [Core Routes](../how-to/CORE_ROUTES.md)
+- [Contributing Guidelines](../../CONTRIBUTING.md)
+- [Main README](../../README.md)
diff --git a/docs/structures/PROJECT_STRUCTURE.md b/docs/structures/PROJECT_STRUCTURE.md
new file mode 100644
index 0000000..638214d
--- /dev/null
+++ b/docs/structures/PROJECT_STRUCTURE.md
@@ -0,0 +1,22 @@
+# Project Structure
+
+This page is the entry point for repository structure documentation.
+
+## Structure Index
+
+1. [Overview and Architecture Flows](./01_OVERVIEW_AND_FLOWS.md)
+2. [Directory Map (Path by Path)](./02_DIRECTORY_MAP.md)
+3. [Best Practices and Workflow](./03_PRACTICES_AND_WORKFLOW.md)
+
+## What To Read
+
+- Read [01_OVERVIEW_AND_FLOWS.md](./01_OVERVIEW_AND_FLOWS.md) for system design, request flow, and architecture diagrams.
+- Read [02_DIRECTORY_MAP.md](./02_DIRECTORY_MAP.md) for folder and file layout.
+- Read [03_PRACTICES_AND_WORKFLOW.md](./03_PRACTICES_AND_WORKFLOW.md) for conventions and development process.
+
+## Related Documentation
+
+- [Development Guide](../how-to/DEVELOPMENT.md)
+- [Core Routes](../how-to/CORE_ROUTES.md)
+- [Contributing Guidelines](../../CONTRIBUTING.md)
+- [Main README](../../README.md)
diff --git a/drizzle.config.ts b/drizzle.config.ts
index ea2682a..3836ad2 100644
--- a/drizzle.config.ts
+++ b/drizzle.config.ts
@@ -1,10 +1,18 @@
+///
+
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
+ driver: "d1-http",
dbCredentials: {
- url: "./data/stats.db",
+ // Database ID from wrangler.toml [[d1_databases]]
+ databaseId: "a2b03b75-e470-480a-acd4-89bad0b42794",
+ // Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_D1_TOKEN in your environment
+ // or .env file before running drizzle-kit migrate / generate.
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
+ token: process.env.CLOUDFLARE_D1_TOKEN!,
},
});
diff --git a/drizzle/0002_stats_requests.sql b/drizzle/0002_stats_requests.sql
new file mode 100644
index 0000000..24867bd
--- /dev/null
+++ b/drizzle/0002_stats_requests.sql
@@ -0,0 +1,8 @@
+CREATE TABLE `stats_requests` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `username` text NOT NULL,
+ `url` text NOT NULL,
+ `created_at` integer
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `uq_stats_request_url` ON `stats_requests` (`url`);
\ No newline at end of file
diff --git a/drizzle/0003_stats_requests_user_agent.sql b/drizzle/0003_stats_requests_user_agent.sql
new file mode 100644
index 0000000..3296550
--- /dev/null
+++ b/drizzle/0003_stats_requests_user_agent.sql
@@ -0,0 +1,7 @@
+DROP INDEX IF EXISTS `uq_stats_request_url`;
+--> statement-breakpoint
+ALTER TABLE `stats_requests` ADD COLUMN `user_agent` text;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS `ix_stats_request_url` ON `stats_requests` (`url`);
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS `ix_stats_request_username` ON `stats_requests` (`username`);
diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..a1660dc
--- /dev/null
+++ b/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,173 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "471a2e0e-677b-4b1c-a4fd-aaae0ec78f47",
+ "prevId": "6959e226-bad1-4ec1-8cc8-60a3b61482a0",
+ "tables": {
+ "badges": {
+ "name": "badges",
+ "columns": {
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visitors": {
+ "name": "visitors",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "repositories": {
+ "name": "repositories",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organization": {
+ "name": "organization",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "languages": {
+ "name": "languages",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "followers": {
+ "name": "followers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_stars": {
+ "name": "total_stars",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_contributors": {
+ "name": "total_contributors",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_commits": {
+ "name": "total_commits",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_code_reviews": {
+ "name": "total_code_reviews",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_issues": {
+ "name": "total_issues",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_pull_requests": {
+ "name": "total_pull_requests",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_joined_years": {
+ "name": "total_joined_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "stats_requests": {
+ "name": "stats_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_stats_request_url": {
+ "name": "uq_stats_request_url",
+ "columns": [
+ "url"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000..9f4c210
--- /dev/null
+++ b/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,187 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "8f2c1a3d-4b6e-4d9a-b2f1-6a0c9d8e7b53",
+ "prevId": "471a2e0e-677b-4b1c-a4fd-aaae0ec78f47",
+ "tables": {
+ "badges": {
+ "name": "badges",
+ "columns": {
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visitors": {
+ "name": "visitors",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "repositories": {
+ "name": "repositories",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "organization": {
+ "name": "organization",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "languages": {
+ "name": "languages",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "followers": {
+ "name": "followers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_stars": {
+ "name": "total_stars",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_contributors": {
+ "name": "total_contributors",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_commits": {
+ "name": "total_commits",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_code_reviews": {
+ "name": "total_code_reviews",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_issues": {
+ "name": "total_issues",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_pull_requests": {
+ "name": "total_pull_requests",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_joined_years": {
+ "name": "total_joined_years",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "stats_requests": {
+ "name": "stats_requests",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ix_stats_request_url": {
+ "name": "ix_stats_request_url",
+ "columns": [
+ "url"
+ ],
+ "isUnique": false
+ },
+ "ix_stats_request_username": {
+ "name": "ix_stats_request_username",
+ "columns": [
+ "username"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index dd8d436..0186b1a 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -15,6 +15,20 @@
"when": 1740369600000,
"tag": "0001_visitor_logs",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "6",
+ "when": 1775284238524,
+ "tag": "0002_stats_requests",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "6",
+ "when": 1785283200000,
+ "tag": "0003_stats_requests_user_agent",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/nginx/default.conf b/nginx/default.conf
new file mode 100644
index 0000000..dad670c
--- /dev/null
+++ b/nginx/default.conf
@@ -0,0 +1,105 @@
+upstream stats_local {
+ zone stats_local 64k;
+ least_conn;
+ server 127.0.0.1:3102 max_conns=256 max_fails=2 fail_timeout=10s;
+ keepalive 32;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name stats.pphat.top;
+
+ # SSL Certificates & Security Protocols
+ ssl_certificate /etc/letsencrypt/live/stats.sophat.top/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/stats.sophat.top/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # Security Hardening & Headers
+ server_tokens off;
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+
+ # Global Proxy Inherited Directives
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_connect_timeout 3s;
+ proxy_send_timeout 10s;
+ proxy_read_timeout 10s;
+ proxy_intercept_errors on;
+ proxy_ssl_server_name on;
+ recursive_error_pages on;
+
+ # Dynamic External DNS Resolver
+ resolver 1.1.1.1 8.8.8.8 valid=60s ipv6=off;
+ resolver_timeout 3s;
+
+ # Primary Upstream (Local Instance)
+ location / {
+ proxy_pass http://stats_local;
+ proxy_set_header Host $host;
+ error_page 429 500 502 503 504 520 521 522 523 524 525 526 530 = @fallback_to_cloudflare;
+ }
+
+ # Fallback Chain 1: Cloudflare Edge
+ location @fallback_to_cloudflare {
+ set $target_cloudflare https://stats1.pphat.top;
+ proxy_ssl_name stats1.pphat.top;
+ proxy_set_header Host stats1.pphat.top;
+ proxy_pass $target_cloudflare;
+ error_page 429 500 502 503 504 520 521 522 523 524 525 526 530 = @fallback_pphat_me;
+ }
+
+ # Fallback Chain 2: Secondary Domain (stats.pphat.me)
+ location @fallback_pphat_me {
+ set $target_pphat_me https://stats.pphat.me;
+ proxy_ssl_name stats.pphat.me;
+ proxy_set_header Host stats.pphat.me;
+ proxy_pass $target_pphat_me;
+ error_page 429 500 502 503 504 520 521 522 523 524 525 526 530 = @fallback_render;
+ }
+
+ # Fallback Chain 3: Render PaaS
+ location @fallback_render {
+ proxy_connect_timeout 5s;
+ proxy_send_timeout 15s;
+ proxy_read_timeout 15s;
+ set $target_render https://github-stats-3hu8.onrender.com;
+ proxy_ssl_name github-stats-3hu8.onrender.com;
+ proxy_set_header Host github-stats-3hu8.onrender.com;
+ proxy_pass $target_render;
+ error_page 429 500 502 503 504 520 521 522 523 524 525 526 530 = @fallback_railway;
+ }
+
+ # Fallback Chain 4: Railway PaaS
+ location @fallback_railway {
+ proxy_connect_timeout 5s;
+ proxy_send_timeout 15s;
+ proxy_read_timeout 15s;
+ set $target_railway https://githubstats.up.railway.app;
+ proxy_ssl_name githubstats.up.railway.app;
+ proxy_set_header Host githubstats.up.railway.app;
+ proxy_pass $target_railway;
+ error_page 429 500 502 503 504 520 521 522 523 524 525 526 530 = @fallback_unavailable;
+ }
+
+ # Final Unavailable Handler
+ location @fallback_unavailable {
+ add_header Cache-Control "no-store" always;
+ return 503;
+ }
+}
+
+# HTTP -> HTTPS Redirect
+server {
+ listen 80;
+ server_name stats.pphat.top;
+ return 301 https://$host$request_uri;
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 3e3f3ef..c07eb46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "github-stats",
- "version": "2.0.0",
+ "version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "github-stats",
- "version": "2.0.0",
+ "version": "2.1.1",
"license": "MIT",
"dependencies": {
"@octokit/rest": "^20.0.2",
@@ -15,25 +15,28 @@
"compression": "^1.8.1",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
- "drizzle-orm": "^0.45.1",
- "express": "^4.18.2",
- "express-rate-limit": "^8.2.2",
+ "drizzle-orm": "^0.45.2",
+ "express": "^4.22.2",
+ "express-rate-limit": "^8.5.1",
"ffmpeg-static": "^5.3.0",
"helmet": "^8.1.0",
+ "lru-cache": "^11.5.3",
+ "minimatch": "^10.2.3",
"node-fetch": "^3.3.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"redis": "^4.6.12",
- "sharp": "^0.33.2",
+ "sharp": "^0.35.3",
"zod": "^4.3.6"
},
"devDependencies": {
+ "@cloudflare/workers-types": "^4.20250408.0",
"@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/helmet": "^0.0.48",
- "@types/node": "^20.10.5",
+ "@types/node": "^20.19.39",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.9",
@@ -42,6 +45,13 @@
"typescript": "^5.3.3"
}
},
+ "node_modules/@cloudflare/workers-types": {
+ "version": "4.20260408.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260408.1.tgz",
+ "integrity": "sha512-kE1tKfHUyIldsj3ea2XEqvLRHkDwc83YM7nar6SS5+cj81IoAFR/OZNDwZWHb6vx+pC31PBJGtROlfZzsgxudQ==",
+ "devOptional": true,
+ "license": "MIT OR Apache-2.0"
+ },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -78,9 +88,9 @@
"license": "Apache-2.0"
},
"node_modules/@emnapi/runtime": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
- "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -112,9 +122,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
- "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
@@ -129,9 +139,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
- "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
@@ -146,9 +156,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
- "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
@@ -163,9 +173,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
- "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
@@ -180,9 +190,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
- "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
@@ -197,9 +207,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
- "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
@@ -214,9 +224,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
- "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
@@ -231,9 +241,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
- "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
@@ -248,9 +258,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
- "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
@@ -265,9 +275,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
- "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
@@ -282,9 +292,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
- "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
@@ -299,9 +309,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
- "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
@@ -316,9 +326,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
- "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
@@ -333,9 +343,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
- "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -350,9 +360,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
- "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
@@ -367,9 +377,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
- "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
@@ -384,9 +394,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
- "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
@@ -401,9 +411,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
- "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
@@ -418,9 +428,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
- "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
@@ -435,9 +445,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
- "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
@@ -452,9 +462,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
- "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
@@ -469,9 +479,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
- "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
@@ -486,9 +496,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
- "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
@@ -503,9 +513,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
- "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
@@ -520,9 +530,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
- "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
@@ -537,9 +547,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
- "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
@@ -553,10 +563,19 @@
"node": ">=18"
}
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
- "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -566,19 +585,19 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
- "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -588,19 +607,38 @@
"darwin"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.0.4"
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
- "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -614,9 +652,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
- "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -630,9 +668,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
- "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
@@ -646,9 +684,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
- "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
@@ -661,10 +699,42 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
- "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
@@ -678,9 +748,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
- "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
@@ -694,9 +764,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
- "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
@@ -710,9 +780,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
- "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
@@ -726,9 +796,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
- "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
@@ -738,19 +808,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.0.5"
+ "@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
- "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
@@ -760,19 +830,63 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.0.4"
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
- "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
@@ -782,19 +896,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.0.4"
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
- "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
@@ -804,19 +918,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.0.4"
+ "@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
- "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
@@ -826,19 +940,19 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
- "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
@@ -848,38 +962,73 @@
"linux"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
- "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
- "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "license": "Apache-2.0",
"optional": true,
"dependencies": {
- "@emnapi/runtime": "^1.2.0"
+ "@img/sharp-wasm32": "0.35.3"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
- "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -889,16 +1038,16 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
- "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -908,7 +1057,7 @@
"win32"
],
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
@@ -1502,9 +1651,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "20.19.35",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
- "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==",
+ "version": "20.19.39",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
+ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -1512,9 +1661,9 @@
}
},
"node_modules/@types/qs": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"dev": true,
"license": "MIT"
},
@@ -1674,6 +1823,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1701,9 +1859,9 @@
"license": "Apache-2.0"
},
"node_modules/better-sqlite3": {
- "version": "12.6.2",
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
- "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
+ "version": "12.8.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
+ "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1735,9 +1893,9 @@
}
},
"node_modules/body-parser": {
- "version": "1.20.4",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
- "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -1748,7 +1906,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
- "qs": "~6.14.0",
+ "qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -1758,6 +1916,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -1847,47 +2017,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/color": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
- "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1",
- "color-string": "^1.9.0"
- },
- "engines": {
- "node": ">=12.5.0"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "license": "MIT"
- },
- "node_modules/color-string": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
- "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
- "license": "MIT",
- "dependencies": {
- "color-name": "^1.0.0",
- "simple-swizzle": "^0.2.2"
- }
- },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -2087,9 +2216,9 @@
}
},
"node_modules/dotenv": {
- "version": "17.3.1",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
- "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
+ "version": "17.4.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
+ "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -2099,25 +2228,25 @@
}
},
"node_modules/drizzle-kit": {
- "version": "0.31.9",
- "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz",
- "integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==",
+ "version": "0.31.10",
+ "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
+ "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@drizzle-team/brocli": "^0.10.2",
"@esbuild-kit/esm-loader": "^2.5.5",
"esbuild": "^0.25.4",
- "esbuild-register": "^3.5.0"
+ "tsx": "^4.21.0"
},
"bin": {
"drizzle-kit": "bin.cjs"
}
},
"node_modules/drizzle-orm": {
- "version": "0.45.1",
- "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz",
- "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==",
+ "version": "0.45.2",
+ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz",
+ "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/client-rds-data": ">=3",
@@ -2305,9 +2434,9 @@
}
},
"node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -2317,9 +2446,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
- "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2330,72 +2459,34 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.3",
- "@esbuild/android-arm": "0.27.3",
- "@esbuild/android-arm64": "0.27.3",
- "@esbuild/android-x64": "0.27.3",
- "@esbuild/darwin-arm64": "0.27.3",
- "@esbuild/darwin-x64": "0.27.3",
- "@esbuild/freebsd-arm64": "0.27.3",
- "@esbuild/freebsd-x64": "0.27.3",
- "@esbuild/linux-arm": "0.27.3",
- "@esbuild/linux-arm64": "0.27.3",
- "@esbuild/linux-ia32": "0.27.3",
- "@esbuild/linux-loong64": "0.27.3",
- "@esbuild/linux-mips64el": "0.27.3",
- "@esbuild/linux-ppc64": "0.27.3",
- "@esbuild/linux-riscv64": "0.27.3",
- "@esbuild/linux-s390x": "0.27.3",
- "@esbuild/linux-x64": "0.27.3",
- "@esbuild/netbsd-arm64": "0.27.3",
- "@esbuild/netbsd-x64": "0.27.3",
- "@esbuild/openbsd-arm64": "0.27.3",
- "@esbuild/openbsd-x64": "0.27.3",
- "@esbuild/openharmony-arm64": "0.27.3",
- "@esbuild/sunos-x64": "0.27.3",
- "@esbuild/win32-arm64": "0.27.3",
- "@esbuild/win32-ia32": "0.27.3",
- "@esbuild/win32-x64": "0.27.3"
- }
- },
- "node_modules/esbuild-register": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
- "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^4.3.4"
- },
- "peerDependencies": {
- "esbuild": ">=0.12 <1"
- }
- },
- "node_modules/esbuild-register/node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
}
},
- "node_modules/esbuild-register/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -2421,14 +2512,14 @@
}
},
"node_modules/express": {
- "version": "4.22.1",
- "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
- "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
- "body-parser": "~1.20.3",
+ "body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -2447,7 +2538,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
- "qs": "~6.14.0",
+ "qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -2467,12 +2558,12 @@
}
},
"node_modules/express-rate-limit": {
- "version": "8.2.2",
- "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.2.tgz",
- "integrity": "sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==",
+ "version": "8.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
+ "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
"license": "MIT",
"dependencies": {
- "ip-address": "10.1.0"
+ "ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
@@ -2654,9 +2745,9 @@
}
},
"node_modules/get-tsconfig": {
- "version": "4.13.6",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
- "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
+ "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2697,9 +2788,9 @@
}
},
"node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -2833,9 +2924,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
- "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
+ "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -2850,11 +2941,14 @@
"node": ">= 0.10"
}
},
- "node_modules/is-arrayish": {
- "version": "0.3.4",
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
- "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
- "license": "MIT"
+ "node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
},
"node_modules/make-error": {
"version": "1.3.6",
@@ -2953,6 +3047,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -2990,9 +3099,9 @@
}
},
"node_modules/node-abi": {
- "version": "3.87.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
- "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
+ "version": "3.89.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
+ "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
@@ -3105,9 +3214,9 @@
}
},
"node_modules/path-to-regexp": {
- "version": "0.1.12",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
- "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/prebuild-install": {
@@ -3170,9 +3279,9 @@
}
},
"node_modules/qs": {
- "version": "6.14.2",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
- "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -3318,9 +3427,9 @@
"license": "MIT"
},
"node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -3381,42 +3490,52 @@
"license": "ISC"
},
"node_modules/sharp": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
- "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
- "hasInstallScript": true,
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
- "color": "^4.2.3",
- "detect-libc": "^2.0.3",
- "semver": "^7.6.3"
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
},
"engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.33.5",
- "@img/sharp-darwin-x64": "0.33.5",
- "@img/sharp-libvips-darwin-arm64": "1.0.4",
- "@img/sharp-libvips-darwin-x64": "1.0.4",
- "@img/sharp-libvips-linux-arm": "1.0.5",
- "@img/sharp-libvips-linux-arm64": "1.0.4",
- "@img/sharp-libvips-linux-s390x": "1.0.4",
- "@img/sharp-libvips-linux-x64": "1.0.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
- "@img/sharp-linux-arm": "0.33.5",
- "@img/sharp-linux-arm64": "0.33.5",
- "@img/sharp-linux-s390x": "0.33.5",
- "@img/sharp-linux-x64": "0.33.5",
- "@img/sharp-linuxmusl-arm64": "0.33.5",
- "@img/sharp-linuxmusl-x64": "0.33.5",
- "@img/sharp-wasm32": "0.33.5",
- "@img/sharp-win32-ia32": "0.33.5",
- "@img/sharp-win32-x64": "0.33.5"
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/side-channel": {
@@ -3439,13 +3558,13 @@
}
},
"node_modules/side-channel-list": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
- "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
- "object-inspect": "^1.13.3"
+ "object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -3536,15 +3655,6 @@
"simple-concat": "^1.0.0"
}
},
- "node_modules/simple-swizzle": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
- "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
- "license": "MIT",
- "dependencies": {
- "is-arrayish": "^0.3.1"
- }
- },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
diff --git a/package.json b/package.json
index acd1949..be160b9 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,15 @@
{
"name": "github-stats",
- "version": "2.0.0",
+ "version": "2.1.1",
"description": "Generate dynamic GitHub stats cards for your README",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
- "dev": "node --watch --no-warnings=ExperimentalWarning --loader ts-node/esm ./src/index.ts",
- "start": "node dist/index.js",
+ "dev": "tsx watch ./src/index.ts",
+ "dev:modular": "tsx watch ./src/server.ts",
+ "start": "node dist/server-cluster.js",
+ "start:single": "node dist/index.js",
"start:cluster": "node dist/server-cluster.js",
"start:production": "NODE_ENV=production node dist/server-cluster.js",
"test": "jest",
@@ -43,25 +45,28 @@
"compression": "^1.8.1",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
- "drizzle-orm": "^0.45.1",
- "express": "^4.18.2",
- "express-rate-limit": "^8.2.2",
+ "drizzle-orm": "^0.45.2",
+ "express": "^4.22.2",
+ "express-rate-limit": "^8.5.1",
"ffmpeg-static": "^5.3.0",
"helmet": "^8.1.0",
+ "lru-cache": "^11.5.3",
+ "minimatch": "^10.2.3",
"node-fetch": "^3.3.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"redis": "^4.6.12",
- "sharp": "^0.33.2",
+ "sharp": "^0.35.3",
"zod": "^4.3.6"
},
"devDependencies": {
+ "@cloudflare/workers-types": "^4.20250408.0",
"@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/helmet": "^0.0.48",
- "@types/node": "^20.10.5",
+ "@types/node": "^20.19.39",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.9",
@@ -70,6 +75,8 @@
"typescript": "^5.3.3"
},
"overrides": {
- "esbuild": ">=0.25.0"
- }
+ "esbuild": ">=0.28.1",
+ "minimatch": "^10.2.3"
+ },
+ "private": true
}
diff --git a/public/assets/icons/algolia.svg b/public/assets/icons/algolia.svg
index 9fc0677..3934eb9 100644
--- a/public/assets/icons/algolia.svg
+++ b/public/assets/icons/algolia.svg
@@ -1 +1,21 @@
-Algolia
\ No newline at end of file
+
+
+
+
+ Algolia
+
+
\ No newline at end of file
diff --git a/public/assets/icons/angular.svg b/public/assets/icons/angular.svg
index 5642242..f01adf0 100644
--- a/public/assets/icons/angular.svg
+++ b/public/assets/icons/angular.svg
@@ -1 +1,21 @@
-Angular
\ No newline at end of file
+
+
+
+
+ Angular
+
+
\ No newline at end of file
diff --git a/public/assets/icons/ansible.svg b/public/assets/icons/ansible.svg
new file mode 100644
index 0000000..113262a
--- /dev/null
+++ b/public/assets/icons/ansible.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/antdesign.svg b/public/assets/icons/antdesign.svg
index f07eab8..e9dffc2 100644
--- a/public/assets/icons/antdesign.svg
+++ b/public/assets/icons/antdesign.svg
@@ -1 +1,21 @@
-Ant Design
\ No newline at end of file
+
+
+
+
+ Ant Design
+
+
\ No newline at end of file
diff --git a/public/assets/icons/apachekafka.svg b/public/assets/icons/apachekafka.svg
new file mode 100644
index 0000000..8461fb0
--- /dev/null
+++ b/public/assets/icons/apachekafka.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/archlinux.svg b/public/assets/icons/archlinux.svg
new file mode 100644
index 0000000..5a1e2fb
--- /dev/null
+++ b/public/assets/icons/archlinux.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/argo.svg b/public/assets/icons/argo.svg
new file mode 100644
index 0000000..0181149
--- /dev/null
+++ b/public/assets/icons/argo.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/awesomelists.svg b/public/assets/icons/awesomelists.svg
new file mode 100644
index 0000000..ef9173e
--- /dev/null
+++ b/public/assets/icons/awesomelists.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/bitcoincash.svg b/public/assets/icons/bitcoincash.svg
index a9f290a..7b43388 100644
--- a/public/assets/icons/bitcoincash.svg
+++ b/public/assets/icons/bitcoincash.svg
@@ -1 +1,21 @@
-Bitcoin Cash
\ No newline at end of file
+
+
+
+
+ Bitcoin Cash
+
+
\ No newline at end of file
diff --git a/public/assets/icons/bootstrap.svg b/public/assets/icons/bootstrap.svg
index db312f8..8ee6956 100644
--- a/public/assets/icons/bootstrap.svg
+++ b/public/assets/icons/bootstrap.svg
@@ -1 +1,21 @@
-Bootstrap
\ No newline at end of file
+
+
+
+
+ Bootstrap
+
+
\ No newline at end of file
diff --git a/public/assets/icons/brave.svg b/public/assets/icons/brave.svg
new file mode 100644
index 0000000..8cc86df
--- /dev/null
+++ b/public/assets/icons/brave.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/bun.svg b/public/assets/icons/bun.svg
index 06af56a..dc47b42 100644
--- a/public/assets/icons/bun.svg
+++ b/public/assets/icons/bun.svg
@@ -1 +1,21 @@
-Bun
\ No newline at end of file
+
+
+
+
+ Bun
+
+
\ No newline at end of file
diff --git a/public/assets/icons/cline.svg b/public/assets/icons/cline.svg
new file mode 100644
index 0000000..e4bd5a3
--- /dev/null
+++ b/public/assets/icons/cline.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/cloudflare.svg b/public/assets/icons/cloudflare.svg
new file mode 100644
index 0000000..343b94a
--- /dev/null
+++ b/public/assets/icons/cloudflare.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/cloudways.svg b/public/assets/icons/cloudways.svg
new file mode 100644
index 0000000..4369227
--- /dev/null
+++ b/public/assets/icons/cloudways.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/codeberg.svg b/public/assets/icons/codeberg.svg
new file mode 100644
index 0000000..a93f4b2
--- /dev/null
+++ b/public/assets/icons/codeberg.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/codeproject.svg b/public/assets/icons/codeproject.svg
new file mode 100644
index 0000000..f660330
--- /dev/null
+++ b/public/assets/icons/codeproject.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/coderabbit.svg b/public/assets/icons/coderabbit.svg
index 784e427..22ef319 100644
--- a/public/assets/icons/coderabbit.svg
+++ b/public/assets/icons/coderabbit.svg
@@ -1 +1,21 @@
-CodeRabbit
\ No newline at end of file
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/csharp.svg b/public/assets/icons/csharp.svg
index fc1169e..0365208 100644
--- a/public/assets/icons/csharp.svg
+++ b/public/assets/icons/csharp.svg
@@ -1,23 +1,37 @@
-
-
-
-
+
+
+
+ d="M36.9975 17.5908C36.9973 17.0995 36.8922 16.6653 36.6797 16.2927C36.471 15.9263 36.1584 15.6192 35.7391 15.3764C32.2779 13.3807 28.8134 11.3911 25.3534 9.3933C24.4206 8.85478 23.5161 8.87441 22.5902 9.42067C21.2126 10.2331 14.3152 14.1848 12.2599 15.3753C11.4134 15.8653 11.0015 16.6152 11.0013 17.5899C11 21.6034 11.0013 25.6168 11 29.6304C11 30.1109 11.1008 30.5366 11.304 30.9037C11.5128 31.2812 11.8298 31.5967 12.2587 31.845C14.3142 33.0355 21.2125 36.9868 22.5898 37.7995C23.5161 38.3461 24.4206 38.3656 25.3537 37.8269C28.8138 35.829 32.2785 33.8395 35.7402 31.8438C36.1692 31.5956 36.4862 31.2798 36.695 30.9026C36.8978 30.5356 36.9989 30.1099 36.9989 29.6293C36.9989 29.6293 36.9989 21.6044 36.9975 17.5908Z"
+ fill="currentColor" />
+ d="M24.0399 23.5713L11.3047 30.9039C11.5135 31.2814 11.8305 31.597 12.2595 31.8453C14.3149 33.0357 21.2132 36.9871 22.5906 37.7998C23.5169 38.3463 24.4213 38.3659 25.3544 37.8271C28.8146 35.8293 32.2793 33.8398 35.741 31.8441C36.1699 31.5959 36.4869 31.2801 36.6957 30.9029L24.0399 23.5713Z"
+ fill="currentColor" />
+ d="M36.9974 17.5911C36.9972 17.0997 36.8921 16.6656 36.6796 16.293L24.0391 23.5713L36.6949 30.9029C36.8977 30.5359 36.9985 30.1102 36.9987 29.6295C36.9987 29.6295 36.9987 21.6047 36.9974 17.5911Z"
+ fill="currentColor" />
+ d="M31.5364 20.8311V22.2016H32.907V20.8311H33.5923V22.2016H34.9629V22.8869H33.5923V24.2575H34.9629V24.9428H33.5923V26.3134H32.907V24.9428H31.5364V26.3134H30.8511V24.9428H29.4805V24.2575H30.8511V22.8869H29.4805V22.2016H30.8511V20.8311H31.5364ZM32.907 22.8869H31.5364V24.2575H32.907V22.8869Z"
+ fill="white" data-foreground="true"/>
+ d="M24.0701 13.9473C27.6451 13.9473 30.7663 15.8888 32.4381 18.7747L32.4218 18.7469L28.2156 21.1688C27.3869 19.7656 25.8677 18.8187 24.125 18.7992L24.0701 18.7989C21.4125 18.7989 19.2579 20.9534 19.2579 23.6109C19.2579 24.48 19.4896 25.2947 19.8926 25.9983C20.7221 27.4464 22.2814 28.4231 24.0701 28.4231C25.8699 28.4231 27.4381 27.434 28.2633 25.9704L28.2433 26.0056L32.4431 28.4386C30.7896 31.3 27.7112 33.2356 24.1777 33.2742L24.0701 33.2747C20.4839 33.2747 17.3537 31.3213 15.6861 28.4201C14.8721 27.0038 14.4062 25.3619 14.4062 23.6109C14.4062 18.2739 18.7328 13.9473 24.0701 13.9473Z"
+ fill="white" data-foreground="true"/>
\ No newline at end of file
diff --git a/public/assets/icons/dart.svg b/public/assets/icons/dart.svg
new file mode 100644
index 0000000..35163f4
--- /dev/null
+++ b/public/assets/icons/dart.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/datadog.svg b/public/assets/icons/datadog.svg
new file mode 100644
index 0000000..60773dd
--- /dev/null
+++ b/public/assets/icons/datadog.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/docker.svg b/public/assets/icons/docker.svg
index 1279009..12c212b 100644
--- a/public/assets/icons/docker.svg
+++ b/public/assets/icons/docker.svg
@@ -1 +1,21 @@
-Docker
\ No newline at end of file
+
+
+
+
+ Docker
+
+
\ No newline at end of file
diff --git a/public/assets/icons/editorconfig.svg b/public/assets/icons/editorconfig.svg
index 7fab50d..57f4e36 100644
--- a/public/assets/icons/editorconfig.svg
+++ b/public/assets/icons/editorconfig.svg
@@ -1 +1,21 @@
-EditorConfig
\ No newline at end of file
+
+
+
+
+ EditorConfig
+
+
\ No newline at end of file
diff --git a/public/assets/icons/ejs.svg b/public/assets/icons/ejs.svg
index b1fcf56..8f6d563 100644
--- a/public/assets/icons/ejs.svg
+++ b/public/assets/icons/ejs.svg
@@ -1 +1,21 @@
-EJS
\ No newline at end of file
+
+
+
+
+ EJS
+
+
\ No newline at end of file
diff --git a/public/assets/icons/emberdotjs.svg b/public/assets/icons/emberdotjs.svg
index f99baee..bbe30e2 100644
--- a/public/assets/icons/emberdotjs.svg
+++ b/public/assets/icons/emberdotjs.svg
@@ -1 +1,21 @@
-Ember.js
\ No newline at end of file
+
+
+
+
+ Ember.js
+
+
\ No newline at end of file
diff --git a/public/assets/icons/expo.svg b/public/assets/icons/expo.svg
index cefcbeb..df085b1 100644
--- a/public/assets/icons/expo.svg
+++ b/public/assets/icons/expo.svg
@@ -1 +1,21 @@
-Expo
\ No newline at end of file
+
+
+
+
+ Expo
+
+
\ No newline at end of file
diff --git a/public/assets/icons/express.svg b/public/assets/icons/express.svg
index 5f0099a..d4a40e8 100644
--- a/public/assets/icons/express.svg
+++ b/public/assets/icons/express.svg
@@ -1 +1,21 @@
-Express
\ No newline at end of file
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/facebook.svg b/public/assets/icons/facebook.svg
index 4ec12e0..a1ea78b 100644
--- a/public/assets/icons/facebook.svg
+++ b/public/assets/icons/facebook.svg
@@ -1 +1,21 @@
-Facebook
\ No newline at end of file
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/fastify.svg b/public/assets/icons/fastify.svg
index e993b06..8e1da30 100644
--- a/public/assets/icons/fastify.svg
+++ b/public/assets/icons/fastify.svg
@@ -1 +1,21 @@
-Fastify
\ No newline at end of file
+
+
+
+
+ Fastify
+
+
\ No newline at end of file
diff --git a/public/assets/icons/flydotio.svg b/public/assets/icons/flydotio.svg
new file mode 100644
index 0000000..9b27d29
--- /dev/null
+++ b/public/assets/icons/flydotio.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/fontawesome.svg b/public/assets/icons/fontawesome.svg
new file mode 100644
index 0000000..322a40a
--- /dev/null
+++ b/public/assets/icons/fontawesome.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/gitcode.svg b/public/assets/icons/gitcode.svg
index 347124a..38c1577 100644
--- a/public/assets/icons/gitcode.svg
+++ b/public/assets/icons/gitcode.svg
@@ -1 +1,21 @@
-GitCode
\ No newline at end of file
+
+
+
+
+ GitCode
+
+
\ No newline at end of file
diff --git a/public/assets/icons/githubactions.svg b/public/assets/icons/githubactions.svg
index 67cb0ee..7d1710f 100644
--- a/public/assets/icons/githubactions.svg
+++ b/public/assets/icons/githubactions.svg
@@ -1 +1,21 @@
-GitHub Actions
\ No newline at end of file
+
+
+
+
+ GitHub Actions
+
+
\ No newline at end of file
diff --git a/public/assets/icons/githubcopilot.svg b/public/assets/icons/githubcopilot.svg
index 9833888..d973f29 100644
--- a/public/assets/icons/githubcopilot.svg
+++ b/public/assets/icons/githubcopilot.svg
@@ -1 +1,21 @@
-GitHub Copilot
\ No newline at end of file
+
+
+
+
+ GitHub Copilot
+
+
\ No newline at end of file
diff --git a/public/assets/icons/gitignoredotio.svg b/public/assets/icons/gitignoredotio.svg
index f3ddd17..448aa9a 100644
--- a/public/assets/icons/gitignoredotio.svg
+++ b/public/assets/icons/gitignoredotio.svg
@@ -1 +1,21 @@
-gitignore.io
\ No newline at end of file
+
+
+
+
+ gitignore.io
+
+
\ No newline at end of file
diff --git a/public/assets/icons/go.svg b/public/assets/icons/go.svg
new file mode 100644
index 0000000..1598da6
--- /dev/null
+++ b/public/assets/icons/go.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/googledrive.svg b/public/assets/icons/googledrive.svg
index af30732..14327cb 100644
--- a/public/assets/icons/googledrive.svg
+++ b/public/assets/icons/googledrive.svg
@@ -1 +1,21 @@
-Google Drive
\ No newline at end of file
+
+
+
+
+ Google Drive
+
+
\ No newline at end of file
diff --git a/public/assets/icons/grafana.svg b/public/assets/icons/grafana.svg
new file mode 100644
index 0000000..53126a2
--- /dev/null
+++ b/public/assets/icons/grafana.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/graphql.svg b/public/assets/icons/graphql.svg
new file mode 100644
index 0000000..9f3d9a9
--- /dev/null
+++ b/public/assets/icons/graphql.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/gravatar.svg b/public/assets/icons/gravatar.svg
index 52afe67..1585d44 100644
--- a/public/assets/icons/gravatar.svg
+++ b/public/assets/icons/gravatar.svg
@@ -1 +1,21 @@
-Gravatar
\ No newline at end of file
+
+
+
+
+ Gravatar
+
+
\ No newline at end of file
diff --git a/public/assets/icons/handlebarsjs.svg b/public/assets/icons/handlebarsjs.svg
index a43d3f3..26cc9a5 100644
--- a/public/assets/icons/handlebarsjs.svg
+++ b/public/assets/icons/handlebarsjs.svg
@@ -1 +1,21 @@
-Handlebars.js
\ No newline at end of file
+
+
+
+
+ Handlebars.js
+
+
\ No newline at end of file
diff --git a/public/assets/icons/html.svg b/public/assets/icons/html.svg
index f272afb..2a26e30 100644
--- a/public/assets/icons/html.svg
+++ b/public/assets/icons/html.svg
@@ -1,5 +1,6 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/htmx.svg b/public/assets/icons/htmx.svg
index ce535aa..4531850 100644
--- a/public/assets/icons/htmx.svg
+++ b/public/assets/icons/htmx.svg
@@ -1 +1,21 @@
-htmx
\ No newline at end of file
+
+
+
+
+ htmx
+
+
\ No newline at end of file
diff --git a/public/assets/icons/huggingface.svg b/public/assets/icons/huggingface.svg
index dfdf7be..decf95d 100644
--- a/public/assets/icons/huggingface.svg
+++ b/public/assets/icons/huggingface.svg
@@ -1 +1,21 @@
-Hugging Face
\ No newline at end of file
+
+
+
+
+ Hugging Face
+
+
\ No newline at end of file
diff --git a/public/assets/icons/i18next.svg b/public/assets/icons/i18next.svg
new file mode 100644
index 0000000..9eb8ee7
--- /dev/null
+++ b/public/assets/icons/i18next.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/iconjar.svg b/public/assets/icons/iconjar.svg
index a6d257e..f07dbc2 100644
--- a/public/assets/icons/iconjar.svg
+++ b/public/assets/icons/iconjar.svg
@@ -1,5 +1,6 @@
-
-
- IconJar
+
\ No newline at end of file
diff --git a/public/assets/icons/immersivetranslate.svg b/public/assets/icons/immersivetranslate.svg
index f19ec7f..449d92f 100644
--- a/public/assets/icons/immersivetranslate.svg
+++ b/public/assets/icons/immersivetranslate.svg
@@ -1 +1,21 @@
-Immersive Translate
\ No newline at end of file
+
+
+
+
+ Immersive Translate
+
+
\ No newline at end of file
diff --git a/public/assets/icons/instagram.svg b/public/assets/icons/instagram.svg
index 28c6a52..9988033 100644
--- a/public/assets/icons/instagram.svg
+++ b/public/assets/icons/instagram.svg
@@ -1 +1,21 @@
-Instagram
\ No newline at end of file
+
+
+
+
+ Instagram
+
+
\ No newline at end of file
diff --git a/public/assets/icons/intellijidea.svg b/public/assets/icons/intellijidea.svg
index 255810d..5075611 100644
--- a/public/assets/icons/intellijidea.svg
+++ b/public/assets/icons/intellijidea.svg
@@ -1 +1,21 @@
-IntelliJ IDEA
\ No newline at end of file
+
+
+
+
+ IntelliJ IDEA
+
+
\ No newline at end of file
diff --git a/public/assets/icons/itunes.svg b/public/assets/icons/itunes.svg
index 2ca3962..39b8fac 100644
--- a/public/assets/icons/itunes.svg
+++ b/public/assets/icons/itunes.svg
@@ -1 +1,21 @@
-iTunes
\ No newline at end of file
+
+
+
+
+ iTunes
+
+
\ No newline at end of file
diff --git a/public/assets/icons/jenkins.svg b/public/assets/icons/jenkins.svg
new file mode 100644
index 0000000..2127469
--- /dev/null
+++ b/public/assets/icons/jenkins.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/jinja.svg b/public/assets/icons/jinja.svg
new file mode 100644
index 0000000..1410797
--- /dev/null
+++ b/public/assets/icons/jinja.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/jitsi.svg b/public/assets/icons/jitsi.svg
index 040789c..756da3c 100644
--- a/public/assets/icons/jitsi.svg
+++ b/public/assets/icons/jitsi.svg
@@ -1 +1,21 @@
-Jitsi
\ No newline at end of file
+
+
+
+
+ Jitsi
+
+
\ No newline at end of file
diff --git a/public/assets/icons/json.svg b/public/assets/icons/json.svg
new file mode 100644
index 0000000..3a0792f
--- /dev/null
+++ b/public/assets/icons/json.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/kalilinux.svg b/public/assets/icons/kalilinux.svg
new file mode 100644
index 0000000..7d7b2db
--- /dev/null
+++ b/public/assets/icons/kalilinux.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/line.svg b/public/assets/icons/line.svg
index 25cdcfa..27c21b2 100644
--- a/public/assets/icons/line.svg
+++ b/public/assets/icons/line.svg
@@ -1 +1,21 @@
-LINE
\ No newline at end of file
+
+
+
+
+ LINE
+
+
\ No newline at end of file
diff --git a/public/assets/icons/linear.svg b/public/assets/icons/linear.svg
index 9a24e46..34885db 100644
--- a/public/assets/icons/linear.svg
+++ b/public/assets/icons/linear.svg
@@ -1 +1,21 @@
-Linear
\ No newline at end of file
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/linux.svg b/public/assets/icons/linux.svg
index c9cff60..01674a9 100644
--- a/public/assets/icons/linux.svg
+++ b/public/assets/icons/linux.svg
@@ -1 +1,21 @@
-Linux
\ No newline at end of file
+
+
+
+
+ Linux
+
+
\ No newline at end of file
diff --git a/public/assets/icons/lit.svg b/public/assets/icons/lit.svg
index fa83aea..6c9244d 100644
--- a/public/assets/icons/lit.svg
+++ b/public/assets/icons/lit.svg
@@ -1 +1,21 @@
-Lit
\ No newline at end of file
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/livewire.svg b/public/assets/icons/livewire.svg
index 4217850..7b18360 100644
--- a/public/assets/icons/livewire.svg
+++ b/public/assets/icons/livewire.svg
@@ -1 +1,21 @@
-Livewire
\ No newline at end of file
+
+
+
+
+ Livewire
+
+
\ No newline at end of file
diff --git a/public/assets/icons/mcp.svg b/public/assets/icons/mcp.svg
new file mode 100644
index 0000000..4a79e3a
--- /dev/null
+++ b/public/assets/icons/mcp.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/meilisearch.svg b/public/assets/icons/meilisearch.svg
index 7e18ba4..7b0464a 100644
--- a/public/assets/icons/meilisearch.svg
+++ b/public/assets/icons/meilisearch.svg
@@ -1 +1,21 @@
-Meilisearch
\ No newline at end of file
+
+
+
+
+ Meilisearch
+
+
\ No newline at end of file
diff --git a/public/assets/icons/modelcontextprotocol.svg b/public/assets/icons/modelcontextprotocol.svg
deleted file mode 100644
index 79e5da1..0000000
--- a/public/assets/icons/modelcontextprotocol.svg
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- Model Context Protocol
-
-
\ No newline at end of file
diff --git a/public/assets/icons/mui.svg b/public/assets/icons/mui.svg
index 6f86490..4d755e6 100644
--- a/public/assets/icons/mui.svg
+++ b/public/assets/icons/mui.svg
@@ -1 +1,21 @@
-MUI
\ No newline at end of file
+
+
+
+
+ MUI
+
+
\ No newline at end of file
diff --git a/public/assets/icons/n8n.svg b/public/assets/icons/n8n.svg
new file mode 100644
index 0000000..85d1437
--- /dev/null
+++ b/public/assets/icons/n8n.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/neovim.svg b/public/assets/icons/neovim.svg
new file mode 100644
index 0000000..b7c8bb5
--- /dev/null
+++ b/public/assets/icons/neovim.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/netlify.svg b/public/assets/icons/netlify.svg
index aafbfc9..f2b0e04 100644
--- a/public/assets/icons/netlify.svg
+++ b/public/assets/icons/netlify.svg
@@ -1 +1,21 @@
-Netlify
\ No newline at end of file
+
+
+
+
+ Netlify
+
+
\ No newline at end of file
diff --git a/public/assets/icons/nodedotjs.svg b/public/assets/icons/nodejs.svg
similarity index 100%
rename from public/assets/icons/nodedotjs.svg
rename to public/assets/icons/nodejs.svg
diff --git a/public/assets/icons/notion.svg b/public/assets/icons/notion.svg
new file mode 100644
index 0000000..546e0e3
--- /dev/null
+++ b/public/assets/icons/notion.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/npm.svg b/public/assets/icons/npm.svg
new file mode 100644
index 0000000..732e042
--- /dev/null
+++ b/public/assets/icons/npm.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/nvm.svg b/public/assets/icons/nvm.svg
new file mode 100644
index 0000000..3f4190e
--- /dev/null
+++ b/public/assets/icons/nvm.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/opensourceinitiative.svg b/public/assets/icons/opensourceinitiative.svg
index 2004bf6..f57604d 100644
--- a/public/assets/icons/opensourceinitiative.svg
+++ b/public/assets/icons/opensourceinitiative.svg
@@ -1 +1,21 @@
-Open Source Initiative
\ No newline at end of file
+
+
+
+
+ Open Source Initiative
+
+
\ No newline at end of file
diff --git a/public/assets/icons/peerlist.svg b/public/assets/icons/peerlist.svg
index 979e898..5cab6b3 100644
--- a/public/assets/icons/peerlist.svg
+++ b/public/assets/icons/peerlist.svg
@@ -1 +1,21 @@
-Peerlist
\ No newline at end of file
+
+
+
+
+ Peerlist
+
+
\ No newline at end of file
diff --git a/public/assets/icons/pm2.svg b/public/assets/icons/pm2.svg
new file mode 100644
index 0000000..88cd83f
--- /dev/null
+++ b/public/assets/icons/pm2.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/postcss.svg b/public/assets/icons/postcss.svg
index 3501c0c..1d97bf3 100644
--- a/public/assets/icons/postcss.svg
+++ b/public/assets/icons/postcss.svg
@@ -1 +1,21 @@
-PostCSS
\ No newline at end of file
+
+
+
+
+ PostCSS
+
+
\ No newline at end of file
diff --git a/public/assets/icons/postman.svg b/public/assets/icons/postman.svg
index 18833bd..cbf4b5f 100644
--- a/public/assets/icons/postman.svg
+++ b/public/assets/icons/postman.svg
@@ -1 +1,21 @@
-Postman
\ No newline at end of file
+
+
+
+
+ Postman
+
+
\ No newline at end of file
diff --git a/public/assets/icons/primevue.svg b/public/assets/icons/primevue.svg
index 2342617..5d644b7 100644
--- a/public/assets/icons/primevue.svg
+++ b/public/assets/icons/primevue.svg
@@ -1 +1,21 @@
-PrimeVue
\ No newline at end of file
+
+
+
+
+ PrimeVue
+
+
\ No newline at end of file
diff --git a/public/assets/icons/prisma.svg b/public/assets/icons/prisma.svg
new file mode 100644
index 0000000..44ee2f2
--- /dev/null
+++ b/public/assets/icons/prisma.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/prometheus.svg b/public/assets/icons/prometheus.svg
new file mode 100644
index 0000000..8d8fdc8
--- /dev/null
+++ b/public/assets/icons/prometheus.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/pug.svg b/public/assets/icons/pug.svg
index b82faef..9cfbe5b 100644
--- a/public/assets/icons/pug.svg
+++ b/public/assets/icons/pug.svg
@@ -1 +1,21 @@
-Pug
\ No newline at end of file
+
+
+
+
+ Pug
+
+
\ No newline at end of file
diff --git a/public/assets/icons/pwa.svg b/public/assets/icons/pwa.svg
index 00d5aa6..0311721 100644
--- a/public/assets/icons/pwa.svg
+++ b/public/assets/icons/pwa.svg
@@ -1 +1,21 @@
-PWA
\ No newline at end of file
+
+
+
+
+ PWA
+
+
\ No newline at end of file
diff --git a/public/assets/icons/quasar.svg b/public/assets/icons/quasar.svg
index 86f4a7b..a33eb53 100644
--- a/public/assets/icons/quasar.svg
+++ b/public/assets/icons/quasar.svg
@@ -1 +1,21 @@
-Quasar
\ No newline at end of file
+
+
+
+
+ Quasar
+
+
\ No newline at end of file
diff --git a/public/assets/icons/qwik.svg b/public/assets/icons/qwik.svg
index 2630234..498547d 100644
--- a/public/assets/icons/qwik.svg
+++ b/public/assets/icons/qwik.svg
@@ -1 +1,21 @@
-Qwik
\ No newline at end of file
+
+
+
+
+ Qwik
+
+
\ No newline at end of file
diff --git a/public/assets/icons/railway.svg b/public/assets/icons/railway.svg
new file mode 100644
index 0000000..3998e6b
--- /dev/null
+++ b/public/assets/icons/railway.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/redwoodjs.svg b/public/assets/icons/redwoodjs.svg
index 5744c7f..84d8b53 100644
--- a/public/assets/icons/redwoodjs.svg
+++ b/public/assets/icons/redwoodjs.svg
@@ -1 +1,21 @@
-RedwoodJS
\ No newline at end of file
+
+
+
+
+ RedwoodJS
+
+
\ No newline at end of file
diff --git a/public/assets/icons/republicofgamers.svg b/public/assets/icons/republicofgamers.svg
new file mode 100644
index 0000000..27a4e00
--- /dev/null
+++ b/public/assets/icons/republicofgamers.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/runrundotit.svg b/public/assets/icons/runrundotit.svg
index 9af931a..7d90192 100644
--- a/public/assets/icons/runrundotit.svg
+++ b/public/assets/icons/runrundotit.svg
@@ -1 +1,21 @@
-Runrun.it
\ No newline at end of file
+
+
+
+
+ Runrun.it
+
+
\ No newline at end of file
diff --git a/public/assets/icons/samsung.svg b/public/assets/icons/samsung.svg
index 791eadb..8fb2f0b 100644
--- a/public/assets/icons/samsung.svg
+++ b/public/assets/icons/samsung.svg
@@ -1,5 +1,6 @@
-
-
-
- Samsung
-
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/sonar.svg b/public/assets/icons/sonar.svg
new file mode 100644
index 0000000..e11091c
--- /dev/null
+++ b/public/assets/icons/sonar.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/soundcloud.svg b/public/assets/icons/soundcloud.svg
index e590e34..2240a05 100644
--- a/public/assets/icons/soundcloud.svg
+++ b/public/assets/icons/soundcloud.svg
@@ -1 +1,21 @@
-SoundCloud
\ No newline at end of file
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/sqlite.svg b/public/assets/icons/sqlite.svg
index 2986e17..5fbc9ad 100644
--- a/public/assets/icons/sqlite.svg
+++ b/public/assets/icons/sqlite.svg
@@ -1 +1,21 @@
-SQLite
\ No newline at end of file
+
+
+
+
+ SQLite
+
+
\ No newline at end of file
diff --git a/public/assets/icons/stencil.svg b/public/assets/icons/stencil.svg
index a84521d..04943bf 100644
--- a/public/assets/icons/stencil.svg
+++ b/public/assets/icons/stencil.svg
@@ -1 +1,21 @@
-Stencil
\ No newline at end of file
+
+
+
+
+ Stencil
+
+
\ No newline at end of file
diff --git a/public/assets/icons/styledcomponents.svg b/public/assets/icons/styledcomponents.svg
index e867fc9..0da23b1 100644
--- a/public/assets/icons/styledcomponents.svg
+++ b/public/assets/icons/styledcomponents.svg
@@ -1,4 +1,5 @@
-
+
+
-
- styled-components
-
-
\ No newline at end of file
+
+
+
+
+
diff --git a/public/assets/icons/sublimetext.svg b/public/assets/icons/sublimetext.svg
index a2277bc..2a72cdf 100644
--- a/public/assets/icons/sublimetext.svg
+++ b/public/assets/icons/sublimetext.svg
@@ -1 +1,21 @@
-Sublime Text
\ No newline at end of file
+
+
+
+
+ Sublime Text
+
+
\ No newline at end of file
diff --git a/public/assets/icons/supabase.svg b/public/assets/icons/supabase.svg
new file mode 100644
index 0000000..7d3ee89
--- /dev/null
+++ b/public/assets/icons/supabase.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/telegram.svg b/public/assets/icons/telegram.svg
index cfab1b7..742f267 100644
--- a/public/assets/icons/telegram.svg
+++ b/public/assets/icons/telegram.svg
@@ -1 +1,21 @@
-Telegram
\ No newline at end of file
+
+
+
+
+ Telegram
+
+
\ No newline at end of file
diff --git a/public/assets/icons/termius.svg b/public/assets/icons/termius.svg
new file mode 100644
index 0000000..e347de4
--- /dev/null
+++ b/public/assets/icons/termius.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/terraform.svg b/public/assets/icons/terraform.svg
new file mode 100644
index 0000000..de757f2
--- /dev/null
+++ b/public/assets/icons/terraform.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/tor.svg b/public/assets/icons/tor.svg
new file mode 100644
index 0000000..d891fcd
--- /dev/null
+++ b/public/assets/icons/tor.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/travisci.svg b/public/assets/icons/travisci.svg
new file mode 100644
index 0000000..826dfeb
--- /dev/null
+++ b/public/assets/icons/travisci.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/icons/ubuntu.svg b/public/assets/icons/ubuntu.svg
index cee4b2a..9d5155c 100644
--- a/public/assets/icons/ubuntu.svg
+++ b/public/assets/icons/ubuntu.svg
@@ -1,21 +1,23 @@
-
-
-
-
-
-
-
\ No newline at end of file
+ }
+ #ubuntu-logo {
+ animation: popup 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards;
+ }
+
+
+
+
+
+
+
+
diff --git a/public/assets/icons/vercel.svg b/public/assets/icons/vercel.svg
index fc9e35a..c774694 100644
--- a/public/assets/icons/vercel.svg
+++ b/public/assets/icons/vercel.svg
@@ -1 +1,21 @@
-Vercel
\ No newline at end of file
+
+
+
+
+ Vercel
+
+
\ No newline at end of file
diff --git a/public/assets/icons/vite.svg b/public/assets/icons/vite.svg
index a544152..7114d6e 100644
--- a/public/assets/icons/vite.svg
+++ b/public/assets/icons/vite.svg
@@ -1 +1,21 @@
-Vite
\ No newline at end of file
+
+
+
+
+ Vite
+
+
\ No newline at end of file
diff --git a/public/assets/icons/vitepress.svg b/public/assets/icons/vitepress.svg
index 04bbef7..0ee7cda 100644
--- a/public/assets/icons/vitepress.svg
+++ b/public/assets/icons/vitepress.svg
@@ -1 +1,21 @@
-VitePress
\ No newline at end of file
+
+
+
+
+ VitePress
+
+
\ No newline at end of file
diff --git a/public/assets/icons/wondersharefilmora.svg b/public/assets/icons/wondersharefilmora.svg
index 4e8e66e..58e88af 100644
--- a/public/assets/icons/wondersharefilmora.svg
+++ b/public/assets/icons/wondersharefilmora.svg
@@ -1 +1,21 @@
-Wondershare Filmora
\ No newline at end of file
+
+
+
+
+ Wondershare Filmora
+
+
\ No newline at end of file
diff --git a/public/assets/icons/xml.svg b/public/assets/icons/xml.svg
index 1726e9d..8300401 100644
--- a/public/assets/icons/xml.svg
+++ b/public/assets/icons/xml.svg
@@ -1 +1,21 @@
-XML
\ No newline at end of file
+
+
+
+
+ XML
+
+
\ No newline at end of file
diff --git a/public/assets/icons/yaml.svg b/public/assets/icons/yaml.svg
index 73f2098..4ead239 100644
--- a/public/assets/icons/yaml.svg
+++ b/public/assets/icons/yaml.svg
@@ -1 +1,21 @@
-YAML
\ No newline at end of file
+
+
+
+
+ YAML
+
+
\ No newline at end of file
diff --git a/public/assets/icons/youtube.svg b/public/assets/icons/youtube.svg
index 6d91eca..2498366 100644
--- a/public/assets/icons/youtube.svg
+++ b/public/assets/icons/youtube.svg
@@ -1 +1,21 @@
-YouTube
\ No newline at end of file
+
+
+
+
+ YouTube
+
+
\ No newline at end of file
diff --git a/public/assets/icons/youtubemusic.svg b/public/assets/icons/youtubemusic.svg
index d3c2538..cf90e85 100644
--- a/public/assets/icons/youtubemusic.svg
+++ b/public/assets/icons/youtubemusic.svg
@@ -1 +1,21 @@
-YouTube Music
\ No newline at end of file
+
+
+
+
+ YouTube Music
+
+
\ No newline at end of file
diff --git a/public/assets/icons/youtubeshorts.svg b/public/assets/icons/youtubeshorts.svg
index 9a322a1..8bb28c6 100644
--- a/public/assets/icons/youtubeshorts.svg
+++ b/public/assets/icons/youtubeshorts.svg
@@ -1 +1,21 @@
-YouTube Shorts
\ No newline at end of file
+
+
+
+
+ YouTube Shorts
+
+
\ No newline at end of file
diff --git a/public/icons-demo.js b/public/icons-demo.js
index 47f69b9..e9c965a 100644
--- a/public/icons-demo.js
+++ b/public/icons-demo.js
@@ -63,12 +63,12 @@ function createCheckSVG() {
}, [polyline]);
}
-/**
- * Fetches SVG text content from the given URL.
- * @param {string} path - URL or path to the SVG asset.
- * @returns {string} The SVG file contents as text.
- * @throws {Error} If the network request fails or the response has a non-OK status.
- */
+/**
+ * Fetches SVG text content from the given URL.
+ * @param {string} path - URL or path to the SVG asset.
+ * @returns {string} The SVG file contents as text.
+ * @throws {Error} If the network request fails or the response has a non-OK status.
+ */
async function fetchSVGContent(path) {
try {
const response = await fetch(path);
diff --git a/render.yaml b/render.yaml
new file mode 100644
index 0000000..c28bb92
--- /dev/null
+++ b/render.yaml
@@ -0,0 +1,13 @@
+services:
+ - type: web
+ name: github-stats
+ runtime: node
+ buildCommand: npm install && npm run build
+ startCommand: node dist/index.js
+ # To run with bun natively on Render, change the above to:
+ # runtime: bun
+ # buildCommand: bun install && bun run build
+ # startCommand: bun dist/index.js
+ envVars:
+ - key: NODE_ENV
+ value: production
diff --git a/scripts/clear-redis-cache.ts b/scripts/clear-redis-cache.ts
index 00d53f4..ed5d251 100644
--- a/scripts/clear-redis-cache.ts
+++ b/scripts/clear-redis-cache.ts
@@ -1,8 +1,9 @@
import 'dotenv/config';
-import { getRedisClient, closeRedisClient, CACHE_KEYS } from '../src/utils/redis-client.js';
+import { getRedisClient, closeRedisClient, CACHE_KEYS } from '../src/shared/utils/redis-client.js';
import { db } from '../src/db/index.js';
import { badges, visitorLogs } from '../src/db/schema.js';
import { like, sql } from 'drizzle-orm';
+import { initializeDatabaseAsync } from '../src/shared/config/db.js';
interface ClearOptions {
pattern?: string;
@@ -70,9 +71,9 @@ async function clearDatabaseCache(options: ClearOptions): Promise {
// Get badges to update (preserve visitors count!)
let badgeRecords: { username: string; visitors: number }[];
if (isAllPattern) {
- badgeRecords = db.select({ username: badges.username, visitors: badges.visitors }).from(badges).all();
+ badgeRecords = await db.select({ username: badges.username, visitors: badges.visitors }).from(badges).all();
} else {
- badgeRecords = db.select({ username: badges.username, visitors: badges.visitors }).from(badges)
+ badgeRecords = await db.select({ username: badges.username, visitors: badges.visitors }).from(badges)
.where(like(badges.username, sqlPattern)).all();
}
@@ -112,10 +113,10 @@ async function clearDatabaseCache(options: ClearOptions): Promise {
if (includeLogs) {
let logCount: number;
if (isAllPattern) {
- const countResult = db.select({ count: sql`count(*)` }).from(visitorLogs).get();
+ const countResult = await db.select({ count: sql`count(*)` }).from(visitorLogs).get();
logCount = countResult?.count ?? 0;
} else {
- const countResult = db.select({ count: sql`count(*)` }).from(visitorLogs)
+ const countResult = await db.select({ count: sql`count(*)` }).from(visitorLogs)
.where(like(visitorLogs.username, sqlPattern)).get();
logCount = countResult?.count ?? 0;
}
@@ -145,6 +146,15 @@ async function clearDatabaseCache(options: ClearOptions): Promise {
async function clearCache(options: ClearOptions = {}): Promise {
const { redis = true, database = true, dryRun = false } = options;
+ if (database) {
+ try {
+ await initializeDatabaseAsync();
+ } catch (error) {
+ console.error('โ Database Initialization Error:', error instanceof Error ? error.message : error);
+ process.exit(1);
+ }
+ }
+
if (dryRun) {
console.log('๐ DRY RUN MODE - No data will be deleted\n');
}
@@ -273,4 +283,8 @@ if (options.help) {
console.log('๐งน Cache Clear Script');
console.log('โ'.repeat(40));
-clearCache(options);
+
+clearCache(options).catch(err => {
+ console.error('Unhandled error:', err);
+ process.exit(1);
+});
diff --git a/scripts/diagnose-contributions.ts b/scripts/diagnose-contributions.ts
new file mode 100644
index 0000000..ac5a6b9
--- /dev/null
+++ b/scripts/diagnose-contributions.ts
@@ -0,0 +1,74 @@
+import 'dotenv/config';
+import { Octokit } from '@octokit/rest';
+
+const username = process.argv[2] ?? 'pphatdev';
+const token = process.env.GITHUB_TOKEN;
+if (!token) {
+ console.error('GITHUB_TOKEN missing');
+ process.exit(1);
+}
+const octokit = new Octokit({ auth: token });
+
+const { data: profile } = await octokit.users.getByUsername({ username });
+const createdAt = new Date(profile.created_at);
+console.log(`user: ${username} created: ${createdAt.toISOString()}`);
+
+const years: Array<{ from: string; to: string; label: number }> = [];
+let y = createdAt.getFullYear();
+const now = new Date();
+while (y <= now.getFullYear()) {
+ const from = new Date(y, 0, 1);
+ const to = new Date(y, 11, 31, 23, 59, 59);
+ years.push({
+ from: (from > createdAt ? from : createdAt).toISOString(),
+ to: (to > now ? now : to).toISOString(),
+ label: y,
+ });
+ y++;
+}
+
+const parts = years.map((_, i) => `$from${i}: DateTime!, $to${i}: DateTime!`).join(', ');
+const sels = years.map((_, i) => `
+ y${i}: contributionsCollection(from: $from${i}, to: $to${i}) {
+ totalCommitContributions
+ totalPullRequestContributions
+ totalIssueContributions
+ totalPullRequestReviewContributions
+ restrictedContributionsCount
+ contributionCalendar { totalContributions }
+ }
+`).join('\n');
+
+const query = `query($u: String!, ${parts}) { user(login: $u) { ${sels} } }`;
+const variables: Record = { u: username };
+years.forEach((r, i) => {
+ variables[`from${i}`] = r.from;
+ variables[`to${i}`] = r.to;
+});
+
+const result: any = await octokit.graphql(query, variables);
+const user = result.user;
+
+console.log('\nyear | commits prs iss reviews restricted | calendar.total | sum(comp)');
+console.log('------|-----------------------------------------|----------------|----------');
+let totalCommits = 0, totalPRs = 0, totalIss = 0, totalRev = 0, totalRest = 0, totalCal = 0;
+for (const r of years) {
+ const i = years.indexOf(r);
+ const y = user[`y${i}`];
+ const c = y.totalCommitContributions;
+ const p = y.totalPullRequestContributions;
+ const iC = y.totalIssueContributions;
+ const rv = y.totalPullRequestReviewContributions;
+ const rs = y.restrictedContributionsCount;
+ const cal = y.contributionCalendar.totalContributions;
+ const compSum = c + p + iC + rv + rs;
+ console.log(`${r.label} | ${String(c).padStart(7)} ${String(p).padStart(4)} ${String(iC).padStart(4)} ${String(rv).padStart(6)} ${String(rs).padStart(10)} | ${String(cal).padStart(14)} | ${String(compSum).padStart(8)}`);
+ totalCommits += c; totalPRs += p; totalIss += iC; totalRev += rv; totalRest += rs; totalCal += cal;
+}
+console.log('------|-----------------------------------------|----------------|----------');
+console.log(`SUM | ${String(totalCommits).padStart(7)} ${String(totalPRs).padStart(4)} ${String(totalIss).padStart(4)} ${String(totalRev).padStart(6)} ${String(totalRest).padStart(10)} | ${String(totalCal).padStart(14)} | ${String(totalCommits+totalPRs+totalIss+totalRev+totalRest).padStart(8)}`);
+
+console.log('\ninterpretation:');
+console.log(` calendar-total (public+restricted): ${totalCal}`);
+console.log(` components sum (c+p+i+rv+restricted): ${totalCommits+totalPRs+totalIss+totalRev+totalRest}`);
+console.log(` if user expects ~15k and profile shows that logged-in only, the difference is private contributions the API doesn't expose to third-party tokens.`);
diff --git a/src/app.ts b/src/app.ts
new file mode 100644
index 0000000..fcc0e16
--- /dev/null
+++ b/src/app.ts
@@ -0,0 +1,180 @@
+/**
+ * Express Application Setup (Modular Architecture)
+ * Initializes the Express app with middleware and module-based routes
+ */
+
+import express, { type Express } from 'express';
+import cors from 'cors';
+import compression from 'compression';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { getEnv } from './shared/config/env.js';
+import { createLogger } from './shared/logs/logger.js';
+import { GitHubClient } from './shared/utils/github-client.js';
+
+// Module route creators
+import { createStatsRouter } from './modules/stats/index.js';
+import { createLanguagesRouter } from './modules/languages/index.js';
+import { createGraphsRouter } from './modules/graphs/index.js';
+import { createBadgesRouter } from './modules/badges/index.js';
+import { createIconsRouter } from './modules/icons/index.js';
+import { createHealthRouter } from './modules/health/index.js';
+import { createUsersRouter } from './modules/users/index.js';
+
+// Shared middleware
+import { errorHandler, trackRequest } from './shared/middlewares/index.js';
+import { securityMiddleware, rateLimiter, strictRateLimiter } from './shared/middlewares/performance.middleware.js';
+import type { ResponseCache } from './shared/utils/response-cache.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const publicDir = path.join(__dirname, '..', 'public');
+
+const logger = createLogger({ module: 'app' });
+
+/**
+ * Create and configure Express application
+ */
+export function createApp(): Express {
+ const app = express();
+ const env = getEnv();
+
+ // Behind Cloudflare โ nginx (one hop). Needed so req.ip is the real client
+ // IP for per-IP rate limiting and downstream visitor-dedup work; without
+ // this, everyone shares the nginx-loopback bucket.
+ app.set('trust proxy', 1);
+
+ // โก๏ธ PERFORMANCE: Enable gzip compression for responses
+ app.use(compression({
+ level: 6,
+ threshold: 1024,
+ }));
+
+ // ๐ SECURITY: Helmet-based headers (see performance.middleware for the
+ // rationale on CSP/COEP/CORP tuning for cross-origin badge embedding).
+ app.use(securityMiddleware);
+
+ // ๐ฆ RATE LIMIT: Global 1000 req / 15 min per IP. Endpoint-specific limits
+ // for GitHub-hitting routes are applied in `initializeRoutes`.
+ app.use(rateLimiter);
+
+ // CORS Configuration.
+ //
+ // In production, only our own origins may send credentialed requests.
+ // In dev, we accept any origin but drop `credentials` โ the combination
+ // of `Access-Control-Allow-Origin: *` + `credentials: true` is invalid
+ // per spec and browsers refuse it anyway, but leaving `credentials: true`
+ // there previously masked the misconfig and encouraged relying on it.
+ app.use(cors(
+ env.APP_ENV === 'production'
+ ? {
+ origin: ['https://stats.pphat.top', 'https://pphat.top'],
+ methods: ['GET', 'POST'],
+ credentials: true,
+ }
+ : {
+ origin: '*',
+ methods: ['GET', 'POST'],
+ credentials: false,
+ },
+ ));
+
+ // Body Parsing Middleware. All API routes are GET so a 10 MB budget was
+ // pure attack surface (L3). Kept minimal for any future POST endpoints.
+ app.use(express.json({ limit: '100kb' }));
+ app.use(express.urlencoded({ extended: true, limit: '100kb' }));
+
+ // Static File Serving. Single mount at `/` (I1). External callers that
+ // used the `/public/...` prefix should update to `/...`; drop the alias
+ // after grep confirms nothing external still depends on it.
+ app.use(express.static(publicDir));
+
+ // Request Logging Middleware (Development only)
+ if (env.DEBUG) {
+ app.use((req, res, next) => {
+ const start = Date.now();
+ res.on('finish', () => {
+ const duration = Date.now() - start;
+ logger.debug(`${req.method} ${req.path}`, {
+ method: req.method,
+ path: req.path,
+ status: res.statusCode,
+ duration: `${duration}ms`,
+ });
+ });
+ next();
+ });
+ }
+
+ logger.info('Express middleware configured');
+
+ return app;
+}
+
+/**
+ * Initialize application routes using modular structure
+ */
+export function initializeRoutes(
+ app: Express,
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number,
+ cacheService?: any
+): void {
+ const logger = createLogger({ module: 'routes' });
+
+ // Root route
+ app.get('/', (req, res) => {
+ res.json({
+ name: 'GitHub Stats API',
+ version: '2.0.0',
+ description: 'Modern GitHub statistics and badge generation service',
+ documentation: '/api-docs',
+ endpoints: {
+ stats: '/stats',
+ languages: '/languages',
+ graphs: '/graph',
+ badges: '/badges',
+ icons: '/icons',
+ health: '/health',
+ users: '/users'
+ }
+ });
+ });
+
+ // Mount module routes. `trackRequest` logs every card request (including
+ // programmatic/bot user-agents) into `stats_requests` for the admin dashboard.
+ // `strictRateLimiter` is layered on /stats and /badges because both may
+ // fan out to the GitHub API on cache miss โ the global rateLimiter alone
+ // would let a hot spot burn through the API quota.
+ app.use('/stats', strictRateLimiter, trackRequest, createStatsRouter(githubClient, cache, cacheDuration));
+ app.use('/languages', trackRequest, createLanguagesRouter(githubClient, cache, cacheDuration));
+ app.use('/graph', trackRequest, createGraphsRouter(githubClient, cache, cacheDuration));
+ app.use('/badges', strictRateLimiter, trackRequest, createBadgesRouter(githubClient, cache, cacheDuration));
+ app.use('/icons', createIconsRouter());
+ app.use('/health', createHealthRouter(cacheService));
+ app.use('/users', createUsersRouter());
+
+ logger.info('Module routes registered');
+}
+
+/**
+ * Setup error handlers for the application
+ */
+export function setupErrorHandlers(app: Express): void {
+ const logger = createLogger({ module: 'error-handler' });
+
+ // 404 Handler
+ app.use((req, res) => {
+ res.status(404).json({
+ error: 'Not Found',
+ message: `Route ${req.method} ${req.path} not found`,
+ documentation: '/api-docs',
+ });
+ });
+
+ // Global Error Handler
+ app.use(errorHandler(logger));
+
+ logger.info('Error handlers configured');
+}
diff --git a/src/cluster.ts b/src/cluster.ts
index 56e6446..1032104 100644
--- a/src/cluster.ts
+++ b/src/cluster.ts
@@ -5,7 +5,7 @@
import cluster from 'cluster';
import os from 'os';
-import { createLogger } from './common/logger.js';
+import { createLogger } from './shared/logs/logger.js';
const logger = createLogger({ service: 'ClusterManager' });
@@ -22,65 +22,95 @@ export async function startCluster(
workerFile: string,
options: ClusterOptions = {}
) {
+ const availableWorkers = typeof os.availableParallelism === 'function'
+ ? os.availableParallelism()
+ : os.cpus().length;
const {
- workers = os.cpus().length,
+ workers = availableWorkers,
respawnDelay = 1000,
maxRestarts = 5
} = options;
+ const workerCount = Math.max(1, Math.min(workers, availableWorkers));
if (cluster.isPrimary) {
+ cluster.schedulingPolicy = cluster.SCHED_RR;
+
logger.info('Starting cluster mode', {
- workers,
- cpus: os.cpus().length,
+ workers: workerCount,
+ cpus: availableWorkers,
platform: os.platform(),
- memory: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)}GB`
+ memory: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)}GB`,
+ schedulingPolicy: 'round-robin'
});
const workerRestarts = new Map();
+ const workerSlots = new Map();
+ let isShuttingDown = false;
+ let healthCheckInterval: NodeJS.Timeout | undefined;
// Spawn workers
- for (let i = 0; i < workers; i++) {
- spawnWorker(i + 1);
+ for (let slot = 1; slot <= workerCount; slot++) {
+ spawnWorker(slot, workerSlots);
}
// Handle worker exit
cluster.on('exit', (worker, code, signal) => {
const workerId = worker.id;
- const restarts = workerRestarts.get(workerId) || 0;
+ const workerSlot = workerSlots.get(workerId) || workerId;
+ const restarts = workerRestarts.get(workerSlot) || 0;
+
+ workerSlots.delete(workerId);
logger.warn('Worker died', {
workerId,
+ workerSlot,
pid: worker.process.pid,
code,
signal,
restarts
});
+ if (isShuttingDown) {
+ return;
+ }
+
// Check if we should respawn
if (restarts < maxRestarts) {
- workerRestarts.set(workerId, restarts + 1);
+ workerRestarts.set(workerSlot, restarts + 1);
setTimeout(() => {
- logger.info('Respawning worker', { workerId, attempt: restarts + 1 });
- spawnWorker(workerId);
+ if (isShuttingDown) {
+ return;
+ }
+
+ logger.info('Respawning worker', { workerId, workerSlot, attempt: restarts + 1 });
+ spawnWorker(workerSlot, workerSlots);
}, respawnDelay);
} else {
- logger.error('Worker exceeded max restarts', undefined, { workerId, maxRestarts });
+ logger.error('Worker exceeded max restarts', undefined, {
+ workerId,
+ workerSlot,
+ maxRestarts,
+ });
}
});
// Handle worker online
cluster.on('online', (worker) => {
+ const workerSlot = workerSlots.get(worker.id) || worker.id;
logger.info('Worker online', {
workerId: worker.id,
+ workerSlot,
pid: worker.process.pid
});
});
// Handle worker listening
cluster.on('listening', (worker, address) => {
+ const workerSlot = workerSlots.get(worker.id) || worker.id;
logger.info('Worker listening', {
workerId: worker.id,
+ workerSlot,
pid: worker.process.pid,
address: `${address.address}:${address.port}`
});
@@ -88,6 +118,14 @@ export async function startCluster(
// Graceful shutdown
const shutdown = async () => {
+ if (isShuttingDown) {
+ return;
+ }
+
+ isShuttingDown = true;
+ if (healthCheckInterval) {
+ clearInterval(healthCheckInterval);
+ }
logger.info('Shutting down cluster...');
const workers = Object.values(cluster.workers || {});
@@ -124,7 +162,7 @@ export async function startCluster(
process.on('SIGINT', shutdown);
// Performance monitoring
- setInterval(() => {
+ healthCheckInterval = setInterval(() => {
const workers = Object.values(cluster.workers || {});
const activeWorkers = workers.filter(w => w && !w.isDead()).length;
@@ -136,22 +174,53 @@ export async function startCluster(
});
}, 60000); // Every minute
+ healthCheckInterval.unref();
+
} else {
// Worker process - import and run the application
try {
- await import(workerFile);
+ const workerModule = await import(workerFile) as {
+ startServer?: () => Promise;
+ stopServer?: () => Promise;
+ };
+ let isWorkerShuttingDown = false;
+
+ if (typeof workerModule.startServer === 'function') {
+ await workerModule.startServer();
+ }
+
+ const shutdownWorker = async () => {
+ if (isWorkerShuttingDown) {
+ return;
+ }
+
+ isWorkerShuttingDown = true;
+ logger.info('Worker received shutdown signal', {
+ workerId: cluster.worker?.id,
+ workerSlot: process.env.WORKER_SLOT,
+ });
+
+ try {
+ await workerModule.stopServer?.();
+ } catch (error) {
+ logger.error('Worker failed to shut down cleanly', error as Error, {
+ workerId: cluster.worker?.id,
+ workerSlot: process.env.WORKER_SLOT,
+ });
+ } finally {
+ process.exit(0);
+ }
+ };
// Handle shutdown signal from master
process.on('message', (msg) => {
if (msg === 'shutdown') {
- logger.info('Worker received shutdown signal', {
- workerId: cluster.worker?.id
- });
-
- // Gracefully close connections
- process.exit(0);
+ void shutdownWorker();
}
});
+
+ process.once('SIGTERM', () => void shutdownWorker());
+ process.once('SIGINT', () => void shutdownWorker());
} catch (error) {
logger.error('Worker failed to start', error as Error, {
@@ -165,9 +234,11 @@ export async function startCluster(
/**
* Spawn a new worker
*/
-function spawnWorker(workerId: number) {
- const worker = cluster.fork();
- worker.id = workerId;
+function spawnWorker(workerSlot: number, workerSlots: Map) {
+ const worker = cluster.fork({
+ WORKER_SLOT: String(workerSlot),
+ });
+ workerSlots.set(worker.id, workerSlot);
return worker;
}
diff --git a/src/common/validation.ts b/src/common/validation.ts
deleted file mode 100644
index d8a88bb..0000000
--- a/src/common/validation.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * Request Validation Schemas
- * Provides runtime validation for API requests using Zod
- */
-
-import { z } from 'zod';
-
-/**
- * Common validations
- */
-const githubUsername = z.string().min(1).max(39).regex(/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/, {
- message: 'Invalid GitHub username format'
-});
-
-const themeSchema = z.string().optional();
-const booleanString = z.enum(['true', 'false']).optional();
-const colorHex = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color').optional();
-const formatSchema = z.enum(['svg', 'webp', 'png']).optional();
-
-/**
- * Stats card request schema
- */
-export const statsQuerySchema = z.object({
- username: githubUsername,
- theme: themeSchema,
- hide_title: booleanString,
- hide_border: booleanString,
- hide_rank: booleanString,
- show_icons: booleanString,
- avatar_mode: z.enum(['none', 'avatar', 'radar']).optional(),
- show_avatar: booleanString, // Backward compatibility
- custom_title: z.string().max(100).optional(),
- data_border_style: z.enum(['solid', 'frame']).optional(),
- data_border_frame: z.enum(['in', 'out']).optional(),
- bgColor: colorHex,
- borderColor: colorHex,
- textColor: colorHex,
- titleColor: colorHex,
- format: formatSchema,
-});
-
-export type StatsQuery = z.infer;
-
-/**
- * Languages card request schema
- */
-export const languagesQuerySchema = z.object({
- username: githubUsername,
- theme: themeSchema,
- show_info: booleanString,
- list_length: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1).max(20)).optional(),
- variant: z.enum(['bubbles', 'pie']).optional(),
- data_border_style: z.enum(['solid', 'frame']).optional(),
- bgColor: colorHex,
- borderColor: colorHex,
- textColor: colorHex,
- titleColor: colorHex,
-});
-
-export type LanguagesQuery = z.infer;
-
-/**
- * Graph request schema
- */
-export const graphQuerySchema = z.object({
- username: githubUsername,
- theme: themeSchema,
- variant: z.enum(['default', 'heatmap', 'bar']).optional(),
- year: z.string().regex(/^\d{4}$/).transform(Number).pipe(z.number().min(2008).max(new Date().getFullYear())).optional(),
- bgColor: colorHex,
- borderColor: colorHex,
- textColor: colorHex,
- iconColor: colorHex,
- hideTitle: booleanString,
-});
-
-export type GraphQuery = z.infer;
-
-/**
- * Badge request schema
- */
-export const badgeQuerySchema = z.object({
- username: githubUsername,
- theme: themeSchema,
- customLabel: z.string().max(50).optional(),
- labelColor: colorHex,
- labelBackground: colorHex,
- valueColor: colorHex,
- valueBackground: colorHex,
-});
-
-export type BadgeQuery = z.infer;
-
-/**
- * Validation helper functions
- */
-
-/**
- * Convert boolean string to actual boolean
- */
-export function parseBooleanString(value: string | undefined, defaultValue: boolean = false): boolean {
- if (value === undefined) return defaultValue;
- return value === 'true';
-}
-
-/**
- * Parse and validate number within range
- */
-export function parseNumberInRange(value: string | undefined, min: number, max: number, defaultValue: number): number {
- if (!value) return defaultValue;
- const parsed = parseInt(value, 10);
- if (isNaN(parsed) || parsed < min || parsed > max) {
- return defaultValue;
- }
- return parsed;
-}
-
-/**
- * Validate hex color
- */
-export function isValidHexColor(color: string): boolean {
- return /^#[0-9A-Fa-f]{6}$/.test(color);
-}
-
-/**
- * Safe validation wrapper that returns validation result
- */
-export function validateRequest(schema: z.ZodSchema, data: unknown): { success: true; data: T } | { success: false; errors: z.ZodError } {
- try {
- const validated = schema.parse(data);
- return { success: true, data: validated };
- } catch (error) {
- if (error instanceof z.ZodError) {
- return { success: false, errors: error };
- }
- throw error;
- }
-}
-
-/**
- * Format Zod validation errors for user-friendly display
- */
-export function formatValidationErrors(error: z.ZodError): string {
- return error.issues
- .map((err: z.ZodIssue) => `${err.path.join('.')}: ${err.message}`)
- .join(', ');
-}
diff --git a/src/config/index.ts b/src/config/index.ts
deleted file mode 100644
index 49b470a..0000000
--- a/src/config/index.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * Centralized Configuration Management
- * Provides type-safe access to environment variables with validation
- */
-
-import 'dotenv/config';
-
-export interface AppConfig {
- server: {
- port: number;
- env: 'development' | 'production' | 'test';
- protocol: 'http' | 'https';
- host: string;
- };
- github: {
- token?: string;
- requestCacheTtl: number;
- };
- cache: {
- duration: number;
- warmupUsername?: string;
- };
- redis: {
- enabled: boolean;
- url?: string;
- host?: string;
- port?: number;
- username?: string;
- password?: string;
- db?: number;
- tls?: boolean;
- connectionTimeout?: number;
- commandTimeout?: number;
- };
- database: {
- url: string;
- };
- monitoring: {
- enableMetrics: boolean;
- enableDebug: boolean;
- };
-}
-
-/**
- * Parse environment variable as integer with default value
- */
-function parseIntEnv(value: string | undefined, defaultValue: number): number {
- if (!value) return defaultValue;
- const parsed = parseInt(value, 10);
- return isNaN(parsed) ? defaultValue : parsed;
-}
-
-/**
- * Parse environment variable as boolean
- */
-function parseBoolEnv(value: string | undefined, defaultValue: boolean): boolean {
- if (!value) return defaultValue;
- return value.toLowerCase() === 'true';
-}
-
-/**
- * Validate required environment variables
- */
-function validateConfig(): void {
- const warnings: string[] = [];
-
- if (!process.env.GITHUB_TOKEN) {
- warnings.push('GITHUB_TOKEN is not set - API rate limits will be restricted');
- }
-
- if (warnings.length > 0) {
- console.warn('\nโ ๏ธ Configuration Warnings:');
- warnings.forEach(w => console.warn(` - ${w}`));
- console.warn('');
- }
-}
-
-/**
- * Load and validate application configuration
- */
-export function loadConfig(): AppConfig {
- const env = (process.env.APP_ENV || 'development') as 'development' | 'production' | 'test';
-
- const config: AppConfig = {
- server: {
- port: parseIntEnv(process.env.PORT, 3000),
- env,
- protocol: env === 'production' ? 'https' : 'http',
- host: process.env.HOST || 'localhost',
- },
- github: {
- token: process.env.GITHUB_TOKEN,
- requestCacheTtl: parseIntEnv(process.env.GITHUB_CACHE_TTL, 30 * 60 * 1000), // 30 min
- },
- cache: {
- duration: parseIntEnv(process.env.CACHE_DURATION, 2 * 60 * 60 * 1000), // 2 hours
- warmupUsername: process.env.WARMUP_USERNAME,
- },
- redis: {
- enabled: parseBoolEnv(process.env.REDIS_ENABLED, true),
- url: process.env.REDIS_URL,
- host: process.env.REDIS_HOST,
- port: parseIntEnv(process.env.REDIS_PORT, 6379),
- username: process.env.REDIS_USERNAME,
- password: process.env.REDIS_PASSWORD,
- db: process.env.REDIS_DB ? parseIntEnv(process.env.REDIS_DB, 0) : undefined,
- tls: process.env.REDIS_TLS ? parseBoolEnv(process.env.REDIS_TLS, false) : undefined,
- connectionTimeout: parseIntEnv(process.env.REDIS_CONNECTION_TIMEOUT, 5000),
- commandTimeout: parseIntEnv(process.env.REDIS_COMMAND_TIMEOUT, 3000),
- },
- database: {
- url: process.env.DATABASE_URL || './data/stats.db',
- },
- monitoring: {
- enableMetrics: parseBoolEnv(process.env.ENABLE_METRICS, true),
- enableDebug: parseBoolEnv(process.env.DEBUG, false),
- },
- };
-
- validateConfig();
-
- return config;
-}
-
-// Singleton configuration instance
-let configInstance: AppConfig | null = null;
-
-/**
- * Get the application configuration
- * Creates the configuration on first call
- */
-export function getConfig(): AppConfig {
- if (!configInstance) {
- configInstance = loadConfig();
- }
- return configInstance;
-}
-
-/**
- * Reset configuration (useful for testing)
- */
-export function resetConfig(): void {
- configInstance = null;
-}
diff --git a/src/controllers/badge.ts b/src/controllers/badge.ts
deleted file mode 100644
index e0c741a..0000000
--- a/src/controllers/badge.ts
+++ /dev/null
@@ -1,560 +0,0 @@
-import crypto from 'node:crypto';
-import { Request, Response } from 'express';
-import { db } from '../db/index.js';
-import { badges, visitorLogs } from '../db/schema.js';
-import { sql, eq } from 'drizzle-orm';
-import { GitHubClient, RepoBadgeType } from '../utils/github-client.js';
-import { BadgeRenderer } from '../components/badge-renderer.js';
-import type { BadgeType, BadgeOptions, UserBadgeType } from '../types.js';
-
-const COMMON_OPTIONAL_PARAMS = [
- 'theme',
- 'customLabel',
- 'labelColor',
- 'labelBackground',
- 'valueColor',
- 'valueBackground',
-];
-
-/** Maps a user-based BadgeType to the matching badges table column key. */
-type PersistedUserBadgeType = Exclude;
-
-/** Maps persisted user badge types to the matching badges table column key. */
-const TYPE_TO_COLUMN: Record = {
- 'repositories': 'repositories',
- 'organization': 'organization',
- 'languages': 'languages',
- 'followers': 'followers',
- 'total-stars': 'total_stars',
- 'total-contributors': 'total_contributors',
- 'total-commits': 'total_commits',
- 'total-code-reviews': 'total_code_reviews',
- 'total-issues': 'total_issues',
- 'total-pull-requests': 'total_pull_requests',
- 'total-joined-years': 'total_joined_years',
-};
-
-export class BadgeController {
- private static githubClient: GitHubClient;
- private static cache: Map;
- private static CACHE_DURATION: number;
- private static pendingRequests: Map> = new Map();
- private static readonly MAX_CACHE_ITEMS = 5000;
- private static readonly HTTP_CACHE_CONTROL = 'public, max-age=600, s-maxage=1800, stale-while-revalidate=86400';
-
- static routeDocs = {
- visitors: { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/visitors?username=pphatdev&theme=tokyo' },
- repositories: { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/repositories?username=pphatdev' },
- organization: { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/organization?username=pphatdev' },
- languages: { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/languages?username=pphatdev' },
- followers: { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/followers?username=pphatdev' },
- 'total-stars': { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/total-stars?username=pphatdev' },
- 'total-contributors': { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/total-contributors?username=pphatdev' },
- 'total-commits': { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/total-commits?username=pphatdev' },
- 'total-code-reviews': { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/total-code-reviews?username=pphatdev' },
- 'total-issues': { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/total-issues?username=pphatdev' },
- 'total-pull-requests': { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/total-pull-requests?username=pphatdev' },
- 'total-joined-years': { requiredParams: ['username'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/total-joined-years?username=pphatdev' },
- // Project/Repository-specific badge routes
- 'repo-stars': { requiredParams: ['owner', 'repo'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/repo-stars?owner=pphatdev&repo=github-stats' },
- 'repo-forks': { requiredParams: ['owner', 'repo'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/repo-forks?owner=pphatdev&repo=github-stats' },
- 'repo-watchers': { requiredParams: ['owner', 'repo'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/repo-watchers?owner=pphatdev&repo=github-stats' },
- 'repo-issues': { requiredParams: ['owner', 'repo'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/repo-issues?owner=pphatdev&repo=github-stats' },
- 'repo-prs': { requiredParams: ['owner', 'repo'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/repo-prs?owner=pphatdev&repo=github-stats' },
- 'repo-contributors': { requiredParams: ['owner', 'repo'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/repo-contributors?owner=pphatdev&repo=github-stats' },
- 'repo-size': { requiredParams: ['owner', 'repo'], optionalParams: COMMON_OPTIONAL_PARAMS, payload: null, example: '/badge/repo-size?owner=pphatdev&repo=github-stats' },
- };
-
- static initialize(
- githubClient: GitHubClient,
- cache: Map,
- cacheDuration: number,
- ) {
- this.githubClient = githubClient;
- this.cache = cache;
- this.CACHE_DURATION = cacheDuration;
- }
-
- private static maybePruneCache(): void {
- if (BadgeController.cache.size <= BadgeController.MAX_CACHE_ITEMS) {
- return;
- }
- const overflowCount = BadgeController.cache.size - BadgeController.MAX_CACHE_ITEMS;
- let removed = 0;
- for (const key of BadgeController.cache.keys()) {
- BadgeController.cache.delete(key);
- removed += 1;
- if (removed >= overflowCount) break;
- }
- }
-
- /** Parse common display options from query params. */
- private static parseOptions(req: Request, type: BadgeType): BadgeOptions {
- const { theme, customLabel, labelColor, labelBackground, valueColor, valueBackground, hideFrame = 'true', hideIcon = 'false' } = req.query;
- return {
- type,
- theme: typeof theme === 'string' ? theme : undefined,
- customLabel: typeof customLabel === 'string' ? customLabel : undefined,
- labelColor: typeof labelColor === 'string' ? labelColor : undefined,
- labelBackground: typeof labelBackground === 'string' ? labelBackground : undefined,
- valueColor: typeof valueColor === 'string' ? valueColor : undefined,
- valueBackground: typeof valueBackground === 'string' ? valueBackground : undefined,
- ...(hideFrame === 'true' ? { hideFrame: true } : {}),
- ...(hideIcon === 'true' ? { hideIcon: true } : {}),
- };
- }
-
- /** Validate username param; sends 400 and returns null on failure. */
- private static requireUsername(req: Request, res: Response): string | null {
- const { username } = req.query;
- if (!username || typeof username !== 'string') {
- res.status(400).send('username is required');
- return null;
- }
- return username;
- }
-
- /** Build a stable cache key from username, badge type, and display options. */
- private static buildCacheKey(username: string, options: BadgeOptions): string {
- return [
- username,
- options.type,
- options.theme ?? 'default',
- options.customLabel ?? '',
- options.labelColor ?? '',
- options.labelBackground ?? '',
- options.valueColor ?? '',
- options.valueBackground ?? '',
- ].join('|');
- }
-
- /** Render a GitHub-data badge โ in-memory โ DB โ GitHub API cache chain. */
- private static async renderGitHubBadge(
- res: Response,
- username: string,
- type: PersistedUserBadgeType,
- options: BadgeOptions,
- ) {
- const cacheKey = BadgeController.buildCacheKey(username, options);
-
- // 1. In-memory SVG cache hit
- const cached = BadgeController.cache.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < BadgeController.CACHE_DURATION) {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', BadgeController.HTTP_CACHE_CONTROL);
- return res.send(cached.data);
- }
-
- // 2. Deduplicate in-flight requests for the same key
- let pending = BadgeController.pendingRequests.get(cacheKey);
- if (!pending) {
- pending = (async () => {
- const col = TYPE_TO_COLUMN[type];
-
- // 3. Check DB cache
- const row = await db.select().from(badges).where(eq(badges.username, username)).get();
- const isStale = !row?.updated_at || (Date.now() - row.updated_at) > BadgeController.CACHE_DURATION;
- const dbValue = row?.[col] as number | null | undefined;
-
- let value: number;
- if (!isStale && dbValue != null) {
- value = dbValue;
- } else {
- // 4. Fetch from GitHub and persist
- value = await BadgeController.githubClient.fetchBadgeValue(username, type);
- await db
- .insert(badges)
- .values({ username, [col]: value, updated_at: Date.now() })
- .onConflictDoUpdate({
- target: badges.username,
- set: { [col]: value, updated_at: Date.now() },
- });
- }
-
- const svg = BadgeRenderer.generateBadge(value, options);
- BadgeController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
- return svg;
- })();
-
- BadgeController.pendingRequests.set(cacheKey, pending);
- pending.then(
- () => BadgeController.pendingRequests.delete(cacheKey),
- () => BadgeController.pendingRequests.delete(cacheKey)
- );
- }
-
- const svg = await pending;
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- return res.send(svg);
- }
-
- /** GET /badge/visitors โ counts unique visitors per IP per calendar day. */
- static async getVisitors(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
-
- // Resolve the real client IP (works behind reverse proxies)
- const rawIp = (
- (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0].trim() ||
- req.socket?.remoteAddress ||
- 'unknown'
- );
-
- // Hash the IP for privacy โ truncated SHA-256 is enough for dedup
- const ipHash = crypto
- .createHash('sha256')
- .update(rawIp)
- .digest('hex')
- .slice(0, 16);
-
- // Calendar date in UTC (YYYY-MM-DD)
- const visitDate = new Date().toISOString().split('T')[0];
-
- // Attempt to record this unique IP+date combination.
- // If the row already exists the insert is a no-op (ON CONFLICT DO NOTHING)
- // and `.returning()` returns an empty array โ meaning we don't double-count.
- const logInsert = await db
- .insert(visitorLogs)
- .values({ username, ip_hash: ipHash, visit_date: visitDate, created_at: Date.now() })
- .onConflictDoNothing()
- .returning();
-
- let count: number;
-
- if (logInsert.length > 0) {
- // New unique visit โ atomically increment the stored total
- const result = await db
- .insert(badges)
- .values({ username, visitors: 1 })
- .onConflictDoUpdate({
- target: badges.username,
- set: { visitors: sql`${badges.visitors} + 1` },
- })
- .returning();
- count = result[0]?.visitors ?? 1;
- } else {
- // Same IP already counted today โ serve the current total without mutating
- const badge = await db
- .select({ visitors: badges.visitors })
- .from(badges)
- .where(eq(badges.username, username))
- .get();
- count = badge?.visitors ?? 0;
- }
-
- const options = BadgeController.parseOptions(req, 'visitors');
- const svg = BadgeRenderer.generateBadge(count, options);
-
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
- return res.send(svg);
- } catch (err) {
- console.error('BadgeController.getVisitors:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/repositories */
- static async getRepositories(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'repositories', BadgeController.parseOptions(req, 'repositories'));
- } catch (err) {
- console.error('BadgeController.getRepositories:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/organization */
- static async getOrganization(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'organization', BadgeController.parseOptions(req, 'organization'));
- } catch (err) {
- console.error('BadgeController.getOrganization:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/languages */
- static async getLanguages(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'languages', BadgeController.parseOptions(req, 'languages'));
- } catch (err) {
- console.error('BadgeController.getLanguages:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/followers */
- static async getFollowers(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'followers', BadgeController.parseOptions(req, 'followers'));
- } catch (err) {
- console.error('BadgeController.getFollowers:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-stars */
- static async getTotalStars(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'total-stars', BadgeController.parseOptions(req, 'total-stars'));
- } catch (err) {
- console.error('BadgeController.getTotalStars:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-contributors */
- static async getTotalContributors(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'total-contributors', BadgeController.parseOptions(req, 'total-contributors'));
- } catch (err) {
- console.error('BadgeController.getTotalContributors:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-commits */
- static async getTotalCommits(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'total-commits', BadgeController.parseOptions(req, 'total-commits'));
- } catch (err) {
- console.error('BadgeController.getTotalCommits:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-code-reviews */
- static async getTotalCodeReviews(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'total-code-reviews', BadgeController.parseOptions(req, 'total-code-reviews'));
- } catch (err) {
- console.error('BadgeController.getTotalCodeReviews:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-issues */
- static async getTotalIssues(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'total-issues', BadgeController.parseOptions(req, 'total-issues'));
- } catch (err) {
- console.error('BadgeController.getTotalIssues:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-pull-requests */
- static async getTotalPullRequests(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'total-pull-requests', BadgeController.parseOptions(req, 'total-pull-requests'));
- } catch (err) {
- console.error('BadgeController.getTotalPullRequests:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-joined-years */
- static async getTotalJoinedYears(req: Request, res: Response) {
- try {
- const username = BadgeController.requireUsername(req, res);
- if (!username) return;
- await BadgeController.renderGitHubBadge(res, username, 'total-joined-years', BadgeController.parseOptions(req, 'total-joined-years'));
- } catch (err) {
- console.error('BadgeController.getTotalJoinedYears:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Project/Repository-specific badge endpoints
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
- /** Validate owner and repo params; sends 400 and returns null on failure. */
- private static requireOwnerRepo(req: Request, res: Response): { owner: string; repo: string } | null {
- const { owner, repo } = req.query;
- if (!owner || typeof owner !== 'string') {
- res.status(400).send('owner is required');
- return null;
- }
- if (!repo || typeof repo !== 'string') {
- res.status(400).send('repo is required');
- return null;
- }
- return { owner, repo };
- }
-
- /** Parse options for repo badges - use repo name as default label context */
- private static parseRepoOptions(req: Request, type: BadgeType): BadgeOptions {
- const { theme, customLabel, labelColor, labelBackground, valueColor, valueBackground, repo } = req.query;
- return {
- type,
- theme: typeof theme === 'string' ? theme : undefined,
- customLabel: typeof customLabel === 'string' ? customLabel : undefined,
- labelColor: typeof labelColor === 'string' ? labelColor : undefined,
- labelBackground: typeof labelBackground === 'string' ? labelBackground : undefined,
- valueColor: typeof valueColor === 'string' ? valueColor : undefined,
- valueBackground: typeof valueBackground === 'string' ? valueBackground : undefined,
- };
- }
-
- /** Build a stable cache key from owner, repo, badge type, and display options. */
- private static buildRepoCacheKey(owner: string, repo: string, options: BadgeOptions): string {
- return [
- owner,
- repo,
- options.type,
- options.theme ?? 'default',
- options.customLabel ?? '',
- options.labelColor ?? '',
- options.labelBackground ?? '',
- options.valueColor ?? '',
- options.valueBackground ?? '',
- ].join('|');
- }
-
- /** Render a repository-specific badge. */
- private static async renderRepoBadge(
- res: Response,
- owner: string,
- repo: string,
- type: RepoBadgeType,
- options: BadgeOptions,
- ) {
- const cacheKey = BadgeController.buildRepoCacheKey(owner, repo, options);
-
- // 1. In-memory SVG cache hit
- const cached = BadgeController.cache.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < BadgeController.CACHE_DURATION) {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- return res.send(cached.data);
- }
-
- // 2. Deduplicate in-flight requests for the same key
- let pending = BadgeController.pendingRequests.get(cacheKey);
- if (!pending) {
- pending = (async () => {
- // Fetch from GitHub
- const value = await BadgeController.githubClient.fetchRepoBadgeValue(owner, repo, type);
- const svg = BadgeRenderer.generateBadge(value, options);
- BadgeController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
- return svg;
- })();
-
- BadgeController.pendingRequests.set(cacheKey, pending);
- pending.finally(() => BadgeController.pendingRequests.delete(cacheKey));
- }
-
- const svg = await pending;
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- return res.send(svg);
- }
-
- /** GET /badge/repo-stars */
- static async getRepoStars(req: Request, res: Response) {
- try {
- const params = BadgeController.requireOwnerRepo(req, res);
- if (!params) return;
- await BadgeController.renderRepoBadge(res, params.owner, params.repo, 'repo-stars', BadgeController.parseRepoOptions(req, 'repo-stars'));
- } catch (err) {
- console.error('BadgeController.getRepoStars:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/repo-forks */
- static async getRepoForks(req: Request, res: Response) {
- try {
- const params = BadgeController.requireOwnerRepo(req, res);
- if (!params) return;
- await BadgeController.renderRepoBadge(res, params.owner, params.repo, 'repo-forks', BadgeController.parseRepoOptions(req, 'repo-forks'));
- } catch (err) {
- console.error('BadgeController.getRepoForks:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/repo-watchers */
- static async getRepoWatchers(req: Request, res: Response) {
- try {
- const params = BadgeController.requireOwnerRepo(req, res);
- if (!params) return;
- await BadgeController.renderRepoBadge(res, params.owner, params.repo, 'repo-watchers', BadgeController.parseRepoOptions(req, 'repo-watchers'));
- } catch (err) {
- console.error('BadgeController.getRepoWatchers:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/repo-issues */
- static async getRepoIssues(req: Request, res: Response) {
- try {
- const params = BadgeController.requireOwnerRepo(req, res);
- if (!params) return;
- await BadgeController.renderRepoBadge(res, params.owner, params.repo, 'repo-issues', BadgeController.parseRepoOptions(req, 'repo-issues'));
- } catch (err) {
- console.error('BadgeController.getRepoIssues:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/repo-prs */
- static async getRepoPrs(req: Request, res: Response) {
- try {
- const params = BadgeController.requireOwnerRepo(req, res);
- if (!params) return;
- await BadgeController.renderRepoBadge(res, params.owner, params.repo, 'repo-prs', BadgeController.parseRepoOptions(req, 'repo-prs'));
- } catch (err) {
- console.error('BadgeController.getRepoPrs:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/repo-contributors */
- static async getRepoContributors(req: Request, res: Response) {
- try {
- const params = BadgeController.requireOwnerRepo(req, res);
- if (!params) return;
- await BadgeController.renderRepoBadge(res, params.owner, params.repo, 'repo-contributors', BadgeController.parseRepoOptions(req, 'repo-contributors'));
- } catch (err) {
- console.error('BadgeController.getRepoContributors:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/repo-size */
- static async getRepoSize(req: Request, res: Response) {
- try {
- const params = BadgeController.requireOwnerRepo(req, res);
- if (!params) return;
- await BadgeController.renderRepoBadge(res, params.owner, params.repo, 'repo-size', BadgeController.parseRepoOptions(req, 'repo-size'));
- } catch (err) {
- console.error('BadgeController.getRepoSize:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-}
diff --git a/src/controllers/controller.ts b/src/controllers/controller.ts
deleted file mode 100644
index 6ecbec8..0000000
--- a/src/controllers/controller.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { themes } from "../utils/themes.js";
-
-export class Controller {
- static defaultConfig = {
- title: 'Github stats - Beautiful, customizable GitHub statistics cards for your README',
- description: 'Create beautiful, customizable GitHub statistics cards for your README. 60+ themes, real-time data, RESTful API. Track stars, commits, PRs, and contributions with stunning visualizations.',
- keywords: 'github stats, github readme, github card, github statistics, readme stats, github api, svg card, github profile, developer stats, contribution tracker',
- page: 'index',
- author: 'pphatdev',
- features: [
- "60+ themes",
- "Real-time data",
- "Highly customizable",
- "Easy integration",
- "RESTful API",
- "Automatic caching",
- "TypeScript support"
- ],
- themes: themes
- };
-}
\ No newline at end of file
diff --git a/src/controllers/graph.ts b/src/controllers/graph.ts
deleted file mode 100644
index 76c57ea..0000000
--- a/src/controllers/graph.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-import { Request, Response } from 'express';
-import { GitHubClient } from '../utils/github-client.js';
-import { GraphRenderer } from '../components/graph-renderer.js';
-import { createLogger } from '../common/logger.js';
-import sharp from 'sharp';
-import { Resvg } from '@resvg/resvg-js';
-import { spawn } from 'child_process';
-import { mkdir, writeFile, readFile, stat } from 'fs/promises';
-import { join, dirname } from 'path';
-import { fileURLToPath } from 'url';
-import { createRequire } from 'module';
-const _require = createRequire(import.meta.url);
-const ffmpegPath = _require('ffmpeg-static') as string;
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-const publicDir = join(__dirname, '..', '..', 'public');
-
-const logger = createLogger({ controller: 'GraphController' });
-
-export class GraphController {
- private static githubClient: GitHubClient;
- private static cache: Map;
- private static CACHE_DURATION: number;
-
- static routeDocs = {
- requiredParams: ['username'],
- optionalParams: [
- 'theme',
- 'year',
- 'animate',
- 'size',
- 'as',
- 'format',
- 'show_title',
- 'show_total_contribution',
- 'show_background',
- 'bgColor',
- 'borderColor',
- 'textColor',
- 'titleColor'
- ],
- payload: null,
- example: '/graph?username=pphatdev&animate=wave'
- };
-
- static initialize(githubClient: GitHubClient, cache: Map, cacheDuration: number) {
- this.githubClient = githubClient;
- this.cache = cache;
- this.CACHE_DURATION = cacheDuration;
- }
-
- static async getSvg(req: Request, res: Response) {
- const startTime = Date.now();
- const timings: { [key: string]: number } = {};
-
- try {
- const { username, theme = 'default', year, animate, size, as: outputFormat, format: formatParam, show_title = 'false', show_total_contribution = 'false', show_background = 'false', bgColor, borderColor, textColor, titleColor } = req.query;
-
- if (!username || typeof username !== 'string') {
- return res.status(400).send('Username is required');
- }
-
- let from: string;
- let to: string;
- let cacheKeyExtra: string;
- let displayYear: string | number;
-
- if (year) {
- const y = parseInt(year as string, 10);
- from = `${y}-01-01T00:00:00Z`;
- to = `${y}-12-31T23:59:59Z`;
- cacheKeyExtra = y.toString();
- displayYear = y;
- } else {
- const now = new Date();
- const oneYearAgo = new Date();
- oneYearAgo.setFullYear(now.getFullYear() - 1);
-
- from = oneYearAgo.toISOString();
- to = now.toISOString();
- cacheKeyExtra = 'last-year';
- displayYear = `${oneYearAgo.getFullYear()}-${now.getFullYear()}`;
- }
-
- const format = typeof outputFormat === 'string' ? outputFormat.toLowerCase() : typeof formatParam === 'string' ? formatParam.toLowerCase() : 'svg';
- const cacheKey = `graph-${username}-${theme}-${cacheKeyExtra}-${animate || ''}-${size || ''}-${show_title ?? ''}-${show_total_contribution ?? ''}-${show_background ?? ''}-${bgColor || ''}-${borderColor || ''}-${textColor || ''}-${titleColor || ''}`;
-
- const apiStartTime = Date.now();
- const contributions = await GraphController.githubClient.fetchUserContributions(username, from, to, cacheKeyExtra);
- timings['github_api'] = Date.now() - apiStartTime;
-
- const cardOptions = {
- theme: theme as string,
- animate: animate as 'none' | 'glow' | 'wave' | 'pulse' | undefined,
- size: size as 'small' | 'medium' | 'large' | 'default' | undefined,
- show_title: show_title === 'false' ? false : true,
- show_total_contribution: show_total_contribution === 'false' ? false : true,
- show_background: show_background === 'false' ? false : true,
- bgColor: bgColor as string | undefined,
- borderColor: borderColor as string | undefined,
- textColor: textColor as string | undefined,
- titleColor: titleColor as string | undefined,
- };
- const graphData = { ...contributions, year: displayYear };
-
- const getSvg = async (): Promise => {
- const cached = GraphController.cache.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < GraphController.CACHE_DURATION) return cached.data;
- const renderStartTime = Date.now();
- const svg = GraphRenderer.generateGraphCard(graphData, cardOptions);
- timings['svg_render'] = Date.now() - renderStartTime;
- GraphController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
- return svg;
- };
-
- if (format === 'webp' || format === 'png') {
- const rasterCacheKey = `${cacheKey}|${format}`;
- const cachedRaster = (GraphController as any)._rasterCache?.get(rasterCacheKey);
- if (cachedRaster && Date.now() - cachedRaster.timestamp < GraphController.CACHE_DURATION) {
- timings['total'] = Date.now() - startTime;
- res.setHeader('X-Timing', JSON.stringify(timings));
- res.setHeader('Content-Type', `image/${format}`);
- res.setHeader('Cache-Control', 'public, max-age=600');
- return res.send(cachedRaster.data);
- }
-
- let buffer: Buffer;
-
- if (format === 'png') {
- // Single static frame using resvg-js for fast, high-quality SVGโPNG
- const svgData = await getSvg();
- const convertStartTime = Date.now();
-
- // Use resvg-js: optimized for SVGโPNG, much faster than sharp
- const resvg = new Resvg(svgData, {
- font: {
- loadSystemFonts: false, // Faster - don't scan system fonts
- fontDirs: [],
- },
- logLevel: 'error',
- });
-
- const pngData = resvg.render();
- buffer = pngData.asPng();
- timings['png_convert'] = Date.now() - convertStartTime;
- } else {
- // Animated WebP โ generate frames and encode with FFmpeg
- const FRAME_COUNT = 20;
- const FRAME_DELAY_MS = 80; // ~12 fps
-
- if (!ffmpegPath) throw new Error('ffmpeg binary not found');
-
- const tmpDir = join(publicDir, 'user', username);
- await mkdir(tmpDir, { recursive: true });
-
- const outFile = join(tmpDir, 'output.webp');
-
- // Re-use the previously rendered file if it's still within cache window
- const reuse = await stat(outFile)
- .then(s => Date.now() - s.mtimeMs < GraphController.CACHE_DURATION)
- .catch(() => false);
-
- if (!reuse) {
- // Rasterize all frames in parallel
- const pngFrames = await Promise.all(
- Array.from({ length: FRAME_COUNT }, (_, i) => {
- const frameSvg = GraphRenderer.generateGraphCard(graphData, cardOptions, i / FRAME_COUNT);
- return sharp(Buffer.from(frameSvg)).png().toBuffer();
- })
- );
-
- // Write frames with zero-padded names (no %-pattern issues on Windows)
- await Promise.all(
- pngFrames.map((buf, i) =>
- writeFile(join(tmpDir, `frame${String(i).padStart(3, '0')}.png`), buf)
- )
- );
-
- // Build an explicit concat file to avoid shell-expanding % on Windows
- const concatLines = pngFrames
- .map((_, i) => `file '${join(tmpDir, `frame${String(i).padStart(3, '0')}.png`).replace(/\\/g, '/')}'\nduration ${FRAME_DELAY_MS / 1000}`)
- .join('\n');
- const concatFile = join(tmpDir, 'concat.txt');
- await writeFile(concatFile, concatLines + '\n');
-
- await new Promise((resolve, reject) => {
- const stderr: Buffer[] = [];
- const proc = spawn(ffmpegPath, [
- '-y',
- '-f', 'concat',
- '-safe', '0',
- '-i', concatFile.replace(/\\/g, '/'),
- '-c:v', 'libwebp_anim',
- '-lossless', '0',
- '-q:v', '75',
- '-compression_level', '4',
- '-loop', '0',
- '-an',
- outFile.replace(/\\/g, '/'),
- ]);
- proc.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk));
- proc.on('close', (code: number) =>
- code === 0
- ? resolve()
- : reject(new Error(`ffmpeg exited ${code}: ${Buffer.concat(stderr).toString().slice(-400)}`))
- );
- });
- }
-
- buffer = await readFile(outFile);
- }
-
- if (!(GraphController as any)._rasterCache) (GraphController as any)._rasterCache = new Map();
- (GraphController as any)._rasterCache.set(rasterCacheKey, { data: buffer, timestamp: Date.now() });
- timings['total'] = Date.now() - startTime;
- res.setHeader('X-Timing', JSON.stringify(timings));
- res.setHeader('Content-Type', `image/${format === 'webp' ? 'webp' : 'png'}`);
- res.setHeader('Cache-Control', 'public, max-age=600');
- return res.send(buffer);
- }
-
- const svg = await getSvg();
- timings['total'] = Date.now() - startTime;
- res.setHeader('X-Timing', JSON.stringify(timings));
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.send(svg);
- } catch (error) {
- timings['total'] = Date.now() - startTime;
- logger.error('Error generating graph', error as Error, { timings });
- res.status(500).send(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
- }
-}
diff --git a/src/controllers/health.controller.ts b/src/controllers/health.controller.ts
deleted file mode 100644
index 0a8d0ed..0000000
--- a/src/controllers/health.controller.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-/**
- * Health Check and Monitoring Endpoints
- * Provides system health status and metrics for monitoring
- */
-
-import { Request, Response } from 'express';
-import { ICacheService } from '../services/base.js';
-import { db } from '../db/index.js';
-import { createLogger } from '../common/logger.js';
-import { getConfig } from '../config/index.js';
-
-const logger = createLogger({ service: 'HealthCheck' });
-
-/**
- * Health check status
- */
-export interface HealthStatus {
- status: 'healthy' | 'degraded' | 'unhealthy';
- timestamp: string;
- uptime: number;
- version: string;
- environment: string;
- checks: {
- database: CheckResult;
- cache: CheckResult;
- memory: CheckResult;
- };
-}
-
-interface CheckResult {
- status: 'pass' | 'fail' | 'warn';
- message?: string;
- responseTime?: number;
- details?: any;
-}
-
-/**
- * Service dependencies
- */
-let cacheService: ICacheService | null = null;
-
-export function initializeHealthCheck(cache: ICacheService): void {
- cacheService = cache;
-}
-
-/**
- * Perform database health check
- */
-async function checkDatabase(): Promise {
- const startTime = Date.now();
-
- try {
- // Simple query to test database
- await db.run('SELECT 1');
-
- return {
- status: 'pass',
- responseTime: Date.now() - startTime,
- };
- } catch (error) {
- logger.error('Database health check failed', error as Error);
- return {
- status: 'fail',
- message: 'Database connection failed',
- responseTime: Date.now() - startTime,
- };
- }
-}
-
-/**
- * Perform cache health check
- */
-async function checkCache(): Promise {
- const startTime = Date.now();
-
- if (!cacheService) {
- return {
- status: 'warn',
- message: 'Cache service not initialized',
- };
- }
-
- try {
- const testKey = '_health_check_';
- const testValue = Date.now().toString();
-
- await cacheService.set(testKey, testValue, 5000);
- const retrieved = await cacheService.get(testKey);
- await cacheService.del(testKey);
-
- if (retrieved !== testValue) {
- return {
- status: 'fail',
- message: 'Cache read/write verification failed',
- responseTime: Date.now() - startTime,
- };
- }
-
- return {
- status: 'pass',
- responseTime: Date.now() - startTime,
- };
- } catch (error) {
- logger.error('Cache health check failed', error as Error);
- return {
- status: 'warn',
- message: 'Cache check failed - falling back to memory cache',
- responseTime: Date.now() - startTime,
- };
- }
-}
-
-/**
- * Check memory usage
- */
-function checkMemory(): CheckResult {
- const usage = process.memoryUsage();
- const heapUsedMB = Math.round(usage.heapUsed / 1024 / 1024);
- const heapTotalMB = Math.round(usage.heapTotal / 1024 / 1024);
- const heapUsagePercent = (usage.heapUsed / usage.heapTotal) * 100;
-
- let status: 'pass' | 'warn' | 'fail' = 'pass';
- let message: string | undefined;
-
- if (heapUsagePercent > 90) {
- status = 'fail';
- message = 'Critical memory usage';
- } else if (heapUsagePercent > 75) {
- status = 'warn';
- message = 'High memory usage';
- }
-
- return {
- status,
- message,
- details: {
- heapUsed: `${heapUsedMB} MB`,
- heapTotal: `${heapTotalMB} MB`,
- heapUsagePercent: `${heapUsagePercent.toFixed(2)}%`,
- rss: `${Math.round(usage.rss / 1024 / 1024)} MB`,
- },
- };
-}
-
-/**
- * Main health check endpoint
- */
-export async function healthCheck(req: Request, res: Response): Promise {
- const startTime = Date.now();
- const config = getConfig();
-
- try {
- // Run all health checks in parallel
- const [databaseCheck, cacheCheck] = await Promise.all([
- checkDatabase(),
- checkCache(),
- ]);
-
- const memoryCheck = checkMemory();
-
- // Determine overall status
- const checks = { database: databaseCheck, cache: cacheCheck, memory: memoryCheck };
- const hasFailure = Object.values(checks).some(check => check.status === 'fail');
- const hasWarning = Object.values(checks).some(check => check.status === 'warn');
-
- let overallStatus: 'healthy' | 'degraded' | 'unhealthy';
- if (hasFailure) {
- overallStatus = 'unhealthy';
- } else if (hasWarning) {
- overallStatus = 'degraded';
- } else {
- overallStatus = 'healthy';
- }
-
- const health: HealthStatus = {
- status: overallStatus,
- timestamp: new Date().toISOString(),
- uptime: process.uptime(),
- version: process.env.npm_package_version || '1.0.0',
- environment: config.server.env,
- checks,
- };
-
- const statusCode = overallStatus === 'healthy' ? 200 : overallStatus === 'degraded' ? 200 : 503;
-
- res.status(statusCode).json(health);
-
- logger.debug('Health check completed', {
- status: overallStatus,
- duration: Date.now() - startTime,
- });
- } catch (error) {
- logger.error('Health check failed', error as Error);
-
- res.status(503).json({
- status: 'unhealthy',
- timestamp: new Date().toISOString(),
- error: 'Health check failed',
- });
- }
-}
-
-/**
- * Liveness probe (simple check)
- */
-export function livenessProbe(req: Request, res: Response): void {
- res.status(200).json({ status: 'alive' });
-}
-
-/**
- * Readiness probe (check if ready to serve traffic)
- */
-export async function readinessProbe(req: Request, res: Response): Promise {
- try {
- // Check critical dependencies
- await checkDatabase();
-
- res.status(200).json({ status: 'ready' });
- } catch (error) {
- logger.error('Readiness check failed', error as Error);
- res.status(503).json({ status: 'not ready' });
- }
-}
-
-/**
- * Metrics endpoint
- */
-export function metrics(req: Request, res: Response): void {
- const usage = process.memoryUsage();
- const config = getConfig();
-
- const metricsData = {
- uptime: process.uptime(),
- memory: {
- heapUsed: usage.heapUsed,
- heapTotal: usage.heapTotal,
- rss: usage.rss,
- external: usage.external,
- },
- process: {
- pid: process.pid,
- nodeVersion: process.version,
- platform: process.platform,
- },
- environment: config.server.env,
- };
-
- res.json(metricsData);
-}
diff --git a/src/controllers/icons.controller.ts b/src/controllers/icons.controller.ts
deleted file mode 100644
index 73dd412..0000000
--- a/src/controllers/icons.controller.ts
+++ /dev/null
@@ -1,402 +0,0 @@
-/**
- * Icons Controller
- * Handles icon-related endpoints for demo and retrieval
- */
-import type { Request, Response } from 'express';
-import fs from 'fs/promises';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import { createHash } from 'crypto';
-import { generateIconsDemoHTML } from '../views/icons-demo.view.js';
-import { IconsCollectionController } from './icons-collection.controller.js';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-export class IconsController {
- private static iconsDir = path.join(__dirname, '..', '..', 'public', 'assets', 'icons');
- private static iconsCache: string[] | null = null;
- private static svgCache: Map = new Map();
- private static pendingLoads: Map> = new Map();
- private static readonly MAX_CACHE_ITEMS = 2000;
- private static readonly HTTP_CACHE_CONTROL = 'public, max-age=31536000, immutable';
- private static readonly COLOR_REGEX = /^(#[0-9A-Fa-f]{3,8}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|hsla\([^)]+\)|[a-zA-Z]+|currentColor)$/;
- private static readonly ICON_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
-
- /**
- * Load and cache icon list
- */
- private static async loadIconsList(): Promise {
- if (!IconsController.iconsCache) {
- const files = await fs.readdir(IconsController.iconsDir);
- IconsController.iconsCache = files
- .filter(file => file.endsWith('.svg'))
- .map(file => file.replace('.svg', ''));
- }
- return IconsController.iconsCache;
- }
-
- /**
- * Create weak ETag from SVG content
- */
- private static createWeakEtag(content: string): string {
- const hash = createHash('sha1').update(content).digest('base64url');
- return `W/"${hash}"`;
- }
-
- /**
- * Prune SVG cache to prevent unbounded memory growth
- */
- private static maybePruneCache(): void {
- if (IconsController.svgCache.size <= IconsController.MAX_CACHE_ITEMS) {
- return;
- }
- const overflowCount = IconsController.svgCache.size - IconsController.MAX_CACHE_ITEMS;
- let removed = 0;
- for (const key of IconsController.svgCache.keys()) {
- IconsController.svgCache.delete(key);
- removed += 1;
- if (removed >= overflowCount) break;
- }
- }
-
- /**
- * Set optimal cache headers for SVG icons
- */
- private static setImageHeaders(res: Response, etag: string): void {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', IconsController.HTTP_CACHE_CONTROL);
- res.setHeader('ETag', etag);
- }
-
- /**
- * Validate color parameter
- */
- private static isValidColor(color: string): boolean {
- return IconsController.COLOR_REGEX.test(color);
- }
-
- /**
- * Resolve icon path after validating the icon name and path boundaries
- */
- private static resolveIconPath(iconName: string): string | null {
- if (!IconsController.ICON_NAME_REGEX.test(iconName)) {
- return null;
- }
-
- const resolvedIconsDir = path.resolve(IconsController.iconsDir);
- const iconPath = path.resolve(IconsController.iconsDir, `${iconName}.svg`);
-
- if (!iconPath.startsWith(resolvedIconsDir + path.sep) && iconPath !== resolvedIconsDir) {
- return null;
- }
-
- return iconPath;
- }
-
- /**
- * Read base icon SVG content with deduplicated concurrent loads
- */
- private static async readBaseIconContent(iconName: string): Promise {
- const iconPath = IconsController.resolveIconPath(iconName);
-
- if (!iconPath) {
- throw new Error('INVALID_ICON_NAME');
- }
-
- let pending = IconsController.pendingLoads.get(iconName);
- if (!pending) {
- pending = fs.readFile(iconPath, 'utf-8');
- IconsController.pendingLoads.set(iconName, pending);
- pending.finally(() => IconsController.pendingLoads.delete(iconName));
- }
-
- return pending;
- }
-
- /**
- * Apply color to SVG content
- * Only replaces currentColor values to preserve intentional color choices
- */
- private static applySvgColor(svgContent: string, color: string): string {
- // Replace fill="currentColor" with the specified color
- let result = svgContent.replace(/fill="currentColor"/gi, `fill="${color}"`);
- result = result.replace(/fill='currentColor'/gi, `fill='${color}'`);
-
- // Replace stroke="currentColor" with the specified color
- result = result.replace(/stroke="currentColor"/gi, `stroke="${color}"`);
- result = result.replace(/stroke='currentColor'/gi, `stroke='${color}'`);
-
- return result;
- }
-
- /**
- * Apply foreground color to SVG content
- * Only replaces colors on elements with data-foreground attribute
- */
- private static applyForegroundColor(svgContent: string, color: string): string {
- // Match complete elements with data-foreground attribute
- let result = svgContent.replace(
- /<([^>]+data-foreground[^>]*)>/gi,
- (match) => {
- // Within this element, replace all fill and stroke attributes
- let modified = match.replace(/fill="[^"]*"/gi, `fill="${color}"`);
- modified = modified.replace(/stroke="[^"]*"/gi, `stroke="${color}"`);
- return modified;
- }
- );
-
- return result;
- }
-
- /**
- * Apply glow effect to SVG content
- * Adds an SVG filter that creates a glow effect around the icon
- */
- private static applyGlowEffect(svgContent: string, glowColor: string): string {
- // Generate a unique filter ID to avoid conflicts
- const filterId = `glow-${Math.random().toString(36).substr(2, 9)}`;
-
- // Create the glow filter definition
- const filterDef = `
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- // Insert the filter definition after the opening SVG tag and apply it to the SVG
- let result = svgContent.replace(
- /(]*)(>)/i,
- (match, svgTag, closingBracket) => {
- // Remove any existing filter attribute first
- const cleanedTag = svgTag.replace(/\s+filter="[^"]*"/gi, '');
- return `${cleanedTag} filter="url(#${filterId})"${closingBracket}${filterDef}`;
- }
- );
-
- return result;
- }
-
- /**
- * Generate cache key with optional color and glow parameters
- */
- private static generateCacheKey(iconName: string, color?: string, foreground?: string, glow?: boolean, glowColor?: string): string {
- const parts = [iconName];
- if (color) parts.push(`c:${color}`);
- if (foreground) parts.push(`fg:${foreground}`);
- if (glow && glowColor) parts.push(`glow:${glowColor}`);
- return parts.join(':');
- }
-
- /**
- * Get all available icons
- */
- static async getAllIcons(req: Request, res: Response): Promise {
- try {
- if (typeof req.query.name !== 'undefined') {
- await IconsCollectionController.getIconsCollection(req, res);
- return;
- }
-
- const icons = await IconsController.loadIconsList();
-
- res.setHeader('Cache-Control', IconsController.HTTP_CACHE_CONTROL);
- res.json({
- count: icons.length,
- icons: icons,
- base_url: '/icons',
- examples: {
- get_icons_svg: '/icons?name=react,typescript&color=%230088CC,%233178C6&size=medium&effect=glow&columns=2',
- get_icon: '/icons/react',
- get_icon_svg: '/icons/react.svg',
- demo_page: '/icons/demo'
- }
- });
- } catch (error) {
- res.status(500).json({
- error: 'Failed to retrieve icons',
- message: error instanceof Error ? error.message : 'Unknown error'
- });
- }
- }
-
- /**
- * Get a specific icon as SVG
- */
- static async getIcon(req: Request, res: Response): Promise {
- try {
- // Strip trailing .svg extension
- const iconName = req.params.name.replace(/\.svg$/, '');
-
- // Extract optional color parameters
- const colorParam = req.query.color as string | undefined;
- const foregroundParam = req.query.foreground as string | undefined;
-
- // Extract optional glow parameters
- const glowParam = req.query.glow === 'true' || req.query.glow === '1';
- const glowColorParam = req.query.glowColor as string | undefined;
-
- if (colorParam && !IconsController.isValidColor(colorParam)) {
- res.status(400).json({
- error: 'Invalid color parameter',
- message: 'Color must be a valid hex color (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba, hsl/hsla, named color, or currentColor'
- });
- return;
- }
-
- if (foregroundParam && !IconsController.isValidColor(foregroundParam)) {
- res.status(400).json({
- error: 'Invalid foreground parameter',
- message: 'Foreground must be a valid hex color (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba, hsl/hsla, named color, or currentColor'
- });
- return;
- }
-
- if (glowColorParam && !IconsController.isValidColor(glowColorParam)) {
- res.status(400).json({
- error: 'Invalid glowColor parameter',
- message: 'glowColor must be a valid hex color (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba, hsl/hsla, named color, or currentColor'
- });
- return;
- }
-
- // If glow is enabled but no glowColor provided, use a default color
- const effectiveGlowColor = glowParam ? (glowColorParam || '#00AAFF') : undefined;
-
- // Validate icon name against strict regex (alphanumeric, dots, underscores, hyphens only)
- if (!IconsController.ICON_NAME_REGEX.test(iconName)) {
- res.status(400).json({
- error: 'Invalid icon name',
- message: 'Icon name must contain only alphanumeric characters, dots, underscores, and hyphens'
- });
- return;
- }
-
- const iconPath = IconsController.resolveIconPath(iconName);
- if (!iconPath) {
- res.status(400).json({
- error: 'Invalid icon path',
- message: 'Icon path must be within the icons directory'
- });
- return;
- }
-
- // Check if icon exists
- try {
- await fs.access(iconPath);
- } catch {
- res.status(404).json({
- error: 'Icon not found',
- icon: iconName,
- available_icons: '/icons'
- });
- return;
- }
-
- // Generate cache key including color and glow parameters
- const cacheKey = IconsController.generateCacheKey(iconName, colorParam, foregroundParam, glowParam, effectiveGlowColor);
-
- // Check in-memory SVG cache first
- const cached = IconsController.svgCache.get(cacheKey);
- if (cached) {
- // ETag validation for 304 responses
- if (req.headers['if-none-match'] === cached.etag) {
- res.status(304).end();
- return;
- }
- IconsController.setImageHeaders(res, cached.etag);
- res.send(cached.content);
- return;
- }
-
- // Deduplicate concurrent loads of the same icon (without color modification)
- let iconContent = await IconsController.readBaseIconContent(iconName);
-
- // Apply color transformations if requested
- if (colorParam) {
- iconContent = IconsController.applySvgColor(iconContent, colorParam);
- }
- if (foregroundParam) {
- iconContent = IconsController.applyForegroundColor(iconContent, foregroundParam);
- }
-
- // Apply glow effect if requested
- if (glowParam && effectiveGlowColor) {
- iconContent = IconsController.applyGlowEffect(iconContent, effectiveGlowColor);
- }
-
- const etag = IconsController.createWeakEtag(iconContent);
-
- // ETag validation for 304 responses
- if (req.headers['if-none-match'] === etag) {
- res.status(304).end();
- return;
- }
-
- // Cache SVG content (including color-modified versions)
- IconsController.svgCache.set(cacheKey, { content: iconContent, etag, timestamp: Date.now() });
- IconsController.maybePruneCache();
-
- IconsController.setImageHeaders(res, etag);
- res.send(iconContent);
- } catch (error) {
- res.status(500).json({
- error: 'Failed to retrieve icon',
- message: error instanceof Error ? error.message : 'Unknown error'
- });
- }
- }
-
- /**
- * Serve the icons demo page
- */
- static async getDemoPage(_req: Request, res: Response): Promise {
- try {
- // Get all icon names using shared helper
- const icons = await IconsController.loadIconsList();
-
- // Generate HTML using view
- const html = generateIconsDemoHTML({ icons });
- res.setHeader('Content-Type', 'text/html');
- res.setHeader('Cache-Control', IconsController.HTTP_CACHE_CONTROL);
- res.send(html);
- } catch (error) {
- res.status(500).json({
- error: 'Failed to load demo page',
- message: error instanceof Error ? error.message : 'Unknown error'
- });
- }
- }
-
- /**
- * Route documentation
- */
- static routeDocs = {
- 'icons-list': {
- requiredParams: [],
- optionalParams: ['name', 'color', 'size', 'effect', 'columns'],
- payload: 'Returns JSON metadata when called without query parameters. When name is provided, returns a composite SVG icon grid. Use comma-separated name and color values, size=small|medium|large, effect=glow|wave, and columns to control layout.',
- example: '/icons or /icons?name=react,typescript&color=%230088CC,%233178C6&size=medium&effect=glow&columns=2'
- },
- 'icons-get': {
- requiredParams: ['name'],
- optionalParams: ['color', 'foreground', 'glow', 'glowColor'],
- payload: 'Returns the SVG content of the specified icon with optional styling. Use "color" to replace currentColor, "foreground" to target data-foreground elements, "glow=true" to enable glow effect, "glowColor" to set glow color (defaults to #00AAFF)',
- example: '/icons/react.svg or /icons/typescript?color=%23FF0000 or /icons/github?glow=true&glowColor=%23FF00FF or /icons/html?foreground=%23FF0000 or /icons/react?color=%230088CC&glow=true&glowColor=%2300FF00'
- },
- 'icons-demo': {
- requiredParams: [],
- optionalParams: [],
- payload: 'Displays an interactive demo page with all available icons',
- example: '/icons/demo'
- }
- };
-}
diff --git a/src/controllers/languages.ts b/src/controllers/languages.ts
deleted file mode 100644
index 3487ec8..0000000
--- a/src/controllers/languages.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Request, Response } from 'express';
-import { GitHubClient } from '../utils/github-client.js';
-import { LanguageCardRenderer } from '../components/language-card.js';
-import { LanguagePieChartRenderer } from '../components/language-pie-chart.js';
-export class LanguageController {
- private static githubClient: GitHubClient;
- private static cache: Map;
- private static CACHE_DURATION: number;
- static routeDocs = {
- requiredParams: ['username'],
- optionalParams: [
- 'type',
- 'theme',
- 'show_info',
- 'info_outline'
- ],
- payload: null as null,
- example: '/languages?username=pphatdev&type=card&theme=default'
- };
-
- static initialize(githubClient: GitHubClient, cache: Map, cacheDuration: number) {
- this.githubClient = githubClient;
- this.cache = cache;
- this.CACHE_DURATION = cacheDuration;
- }
-
- static async getSvg(req: Request, res: Response) {
- try {
- const { username, type = 'card', theme = 'default', show_info, info_outline = 'solid' } = req.query;
-
- if (!username || typeof username !== 'string') {
- return res.status(400).send('Username is required');
- }
-
- const cacheKey = `languages-${username}-${type}-${theme}-${show_info}-${info_outline}`;
- const cached = LanguageController.cache.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < LanguageController.CACHE_DURATION) {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- return res.send(cached.data);
- }
-
- const languages = await LanguageController.githubClient.fetchUserLanguages(username);
-
- let svg: string;
- if (type === 'pie') {
- svg = LanguagePieChartRenderer.generatePieChart(languages, {
- theme: theme as string,
- });
- } else {
- svg = LanguageCardRenderer.generateLanguagesCard(languages, {
- theme: theme as string,
- showInfo: show_info !== 'false',
- dataBorderStyle: info_outline === 'frame' ? 'frame' : 'solid',
- });
- }
-
- LanguageController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
-
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.send(svg);
- } catch (error) {
- console.error('Error generating languages background:', error);
- res.status(500).send(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
- }
-}
diff --git a/src/controllers/project-badge.controller.ts b/src/controllers/project-badge.controller.ts
deleted file mode 100644
index b8e1fc9..0000000
--- a/src/controllers/project-badge.controller.ts
+++ /dev/null
@@ -1,420 +0,0 @@
-/**
- * Project Badge Controller
- * Handles repository/project-specific badge endpoints
- * Features: Redis persistent caching, request deduplication, GitHub API optimization
- */
-import crypto from 'node:crypto';
-import { Request, Response } from 'express';
-import { eq, sql } from 'drizzle-orm';
-import { db } from '../db/index.js';
-import { badges, visitorLogs } from '../db/schema.js';
-import { GitHubClient, RepoBadgeType } from '../utils/github-client.js';
-import { BadgeRenderer } from '../components/badge-renderer.js';
-import { getBadgeCacheServiceSync } from '../services/badge-cache.service.js';
-import type { BadgeOptions, ProjectBadgeType, BadgeRouteDoc } from '../types/badge.types.js';
-
-export class ProjectBadgeController {
- private static githubClient: GitHubClient;
- private static cache: Map;
- private static CACHE_DURATION: number;
- private static pendingRequests: Map> = new Map();
- private static readonly MAX_CACHE_ITEMS = 5000;
- private static readonly HTTP_CACHE_CONTROL = 'public, max-age=600, s-maxage=1800, stale-while-revalidate=86400';
-
- /** Route documentation for project badges */
- static routeDocs: Record = {
- 'repo-visitors': {
- requiredParams: ['repo'],
- optionalParams: ['theme', 'customLabel', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
- payload: null,
- example: '/project/visitors?repo=pphatdev/github-stats'
- },
- 'repo-stars': {
- requiredParams: ['repo'],
- optionalParams: ['theme', 'customLabel', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
- payload: null,
- example: '/project/stars?repo=pphatdev/github-stats'
- },
- 'repo-forks': {
- requiredParams: ['repo'],
- optionalParams: ['theme', 'customLabel', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
- payload: null,
- example: '/project/forks?repo=pphatdev/github-stats'
- },
- 'repo-watchers': {
- requiredParams: ['repo'],
- optionalParams: ['theme', 'customLabel', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
- payload: null,
- example: '/project/watchers?repo=pphatdev/github-stats'
- },
- 'repo-issues': {
- requiredParams: ['repo'],
- optionalParams: ['theme', 'customLabel', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
- payload: null,
- example: '/project/issues?repo=pphatdev/github-stats'
- },
- 'repo-prs': {
- requiredParams: ['repo'],
- optionalParams: ['theme', 'customLabel', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
- payload: null,
- example: '/project/prs?repo=pphatdev/github-stats'
- },
- 'repo-contributors': {
- requiredParams: ['repo'],
- optionalParams: ['theme', 'customLabel', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
- payload: null,
- example: '/project/contributors?repo=pphatdev/github-stats'
- },
- 'repo-size': {
- requiredParams: ['repo'],
- optionalParams: ['theme', 'customLabel', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
- payload: null,
- example: '/project/size?repo=pphatdev/github-stats'
- },
- };
-
- /**
- * Initialize the controller with dependencies
- */
- static initialize(
- githubClient: GitHubClient,
- cache: Map,
- cacheDuration: number,
- ) {
- this.githubClient = githubClient;
- this.cache = cache;
- this.CACHE_DURATION = cacheDuration;
- }
-
- private static maybePruneCache(): void {
- if (ProjectBadgeController.cache.size <= ProjectBadgeController.MAX_CACHE_ITEMS) {
- return;
- }
- const overflowCount = ProjectBadgeController.cache.size - ProjectBadgeController.MAX_CACHE_ITEMS;
- let removed = 0;
- for (const key of ProjectBadgeController.cache.keys()) {
- ProjectBadgeController.cache.delete(key);
- removed += 1;
- if (removed >= overflowCount) break;
- }
- }
-
- /** Validate and parse repo param (format: owner/repo); sends 400 and returns null on failure. */
- private static requireRepo(req: Request, res: Response): { owner: string; repo: string } | null {
- const { repo } = req.query;
- if (!repo || typeof repo !== 'string') {
- res.status(400).send('repo is required (format: owner/repo)');
- return null;
- }
- const parts = repo.split('/');
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
- res.status(400).send('repo must be in format: owner/repo');
- return null;
- }
- return { owner: parts[0], repo: parts[1] };
- }
-
- /** Parse options for project badges */
- private static parseOptions(req: Request, type: ProjectBadgeType): BadgeOptions {
- const { theme, customLabel, labelColor, labelBackground, valueColor, valueBackground } = req.query;
- return {
- type,
- theme: typeof theme === 'string' ? theme : undefined,
- customLabel: typeof customLabel === 'string' ? customLabel : undefined,
- labelColor: typeof labelColor === 'string' ? labelColor : undefined,
- labelBackground: typeof labelBackground === 'string' ? labelBackground : undefined,
- valueColor: typeof valueColor === 'string' ? valueColor : undefined,
- valueBackground: typeof valueBackground === 'string' ? valueBackground : undefined,
- };
- }
-
- /** Build a stable cache key from owner, repo, badge type, and display options. */
- private static buildCacheKey(owner: string, repo: string, options: BadgeOptions): string {
- return [
- 'project',
- owner,
- repo,
- options.type,
- options.theme ?? 'default',
- options.customLabel ?? '',
- options.labelColor ?? '',
- options.labelBackground ?? '',
- options.valueColor ?? '',
- options.valueBackground ?? '',
- ].join('|');
- }
-
- /** Convert BadgeOptions to Record for Redis caching */
- private static optionsToRecord(options: BadgeOptions): Record {
- return {
- theme: options.theme,
- customLabel: options.customLabel,
- labelColor: options.labelColor,
- labelBackground: options.labelBackground,
- valueColor: options.valueColor,
- valueBackground: options.valueBackground,
- };
- }
-
- /** Render a repository-specific badge with Redis caching. */
- private static async renderBadge(
- res: Response,
- owner: string,
- repo: string,
- type: RepoBadgeType,
- options: BadgeOptions,
- ) {
- const cacheKey = ProjectBadgeController.buildCacheKey(owner, repo, options);
- const badgeService = getBadgeCacheServiceSync();
- const optionsRecord = ProjectBadgeController.optionsToRecord(options);
-
- // 1. Check Redis persistent cache first
- if (badgeService?.isReady()) {
- const redisCached = await badgeService.getProjectBadgeSVG(owner, repo, type, optionsRecord);
- if (redisCached) {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.setHeader('X-Cache', 'REDIS');
- return res.send(redisCached.svg);
- }
- }
-
- // 2. Check in-memory SVG cache hit
- const cached = ProjectBadgeController.cache.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < ProjectBadgeController.CACHE_DURATION) {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.setHeader('X-Cache', 'MEMORY');
- return res.send(cached.data);
- }
-
- // 3. Deduplicate in-flight requests for the same key
- let pending = ProjectBadgeController.pendingRequests.get(cacheKey);
- if (!pending) {
- pending = (async () => {
- // Fetch from GitHub
- const value = await ProjectBadgeController.githubClient.fetchRepoBadgeValue(owner, repo, type);
- const svg = BadgeRenderer.generateBadge(value, options);
-
- // Cache in both layers
- ProjectBadgeController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
-
- // Cache in Redis
- if (badgeService?.isReady()) {
- await badgeService.setProjectBadgeSVG(owner, repo, type, optionsRecord, {
- svg,
- value,
- timestamp: Date.now(),
- dbTimestamp: Date.now(),
- });
- }
-
- return svg;
- })();
-
- ProjectBadgeController.pendingRequests.set(cacheKey, pending);
- pending.then(
- () => ProjectBadgeController.pendingRequests.delete(cacheKey),
- () => ProjectBadgeController.pendingRequests.delete(cacheKey)
- );
- }
-
- const svg = await pending;
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.setHeader('X-Cache', 'MISS');
- return res.send(svg);
- }
-
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Badge Endpoints
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
- /** GET /project/visitors - Repository visitors (counted once per IP per 5 minutes) */
- static async getVisitors(req: Request, res: Response) {
- try {
- const params = ProjectBadgeController.requireRepo(req, res);
- if (!params) return;
- const projectKey = `project:${params.owner}/${params.repo}`;
-
- const rawIp = (
- (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0].trim() ||
- req.socket?.remoteAddress ||
- 'unknown'
- );
-
- const ipHash = crypto
- .createHash('sha256')
- .update(rawIp)
- .digest('hex')
- .slice(0, 16);
-
- const FIVE_MINUTES_MS = 5 * 60 * 1000;
- const bucketStartMs = Math.floor(Date.now() / FIVE_MINUTES_MS) * FIVE_MINUTES_MS;
- const visitBucket = new Date(bucketStartMs).toISOString();
-
- // Count only once per IP per 5-minute bucket for each project.
- const logInsert = await db
- .insert(visitorLogs)
- .values({ username: projectKey, ip_hash: ipHash, visit_date: visitBucket, created_at: Date.now() })
- .onConflictDoNothing()
- .returning();
-
- let count: number;
- if (logInsert.length > 0) {
- const result = await db
- .insert(badges)
- .values({ username: projectKey, visitors: 1 })
- .onConflictDoUpdate({
- target: badges.username,
- set: { visitors: sql`${badges.visitors} + 1` },
- })
- .returning();
- count = result[0]?.visitors ?? 1;
- } else {
- const badge = await db
- .select({ visitors: badges.visitors })
- .from(badges)
- .where(eq(badges.username, projectKey))
- .get();
- count = badge?.visitors ?? 0;
- }
-
- const options = ProjectBadgeController.parseOptions(req, 'repo-visitors');
- const svg = BadgeRenderer.generateBadge(count, options);
-
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
- return res.send(svg);
- } catch (err) {
- console.error('ProjectBadgeController.getVisitors:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /project/stars - Repository star count */
- static async getStars(req: Request, res: Response) {
- try {
- const params = ProjectBadgeController.requireRepo(req, res);
- if (!params) return;
- await ProjectBadgeController.renderBadge(
- res,
- params.owner,
- params.repo,
- 'repo-stars',
- ProjectBadgeController.parseOptions(req, 'repo-stars')
- );
- } catch (err) {
- console.error('ProjectBadgeController.getStars:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /project/forks - Repository fork count */
- static async getForks(req: Request, res: Response) {
- try {
- const params = ProjectBadgeController.requireRepo(req, res);
- if (!params) return;
- await ProjectBadgeController.renderBadge(
- res,
- params.owner,
- params.repo,
- 'repo-forks',
- ProjectBadgeController.parseOptions(req, 'repo-forks')
- );
- } catch (err) {
- console.error('ProjectBadgeController.getForks:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /project/watchers - Repository watcher count */
- static async getWatchers(req: Request, res: Response) {
- try {
- const params = ProjectBadgeController.requireRepo(req, res);
- if (!params) return;
- await ProjectBadgeController.renderBadge(
- res,
- params.owner,
- params.repo,
- 'repo-watchers',
- ProjectBadgeController.parseOptions(req, 'repo-watchers')
- );
- } catch (err) {
- console.error('ProjectBadgeController.getWatchers:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /project/issues - Repository open issues count */
- static async getIssues(req: Request, res: Response) {
- try {
- const params = ProjectBadgeController.requireRepo(req, res);
- if (!params) return;
- await ProjectBadgeController.renderBadge(
- res,
- params.owner,
- params.repo,
- 'repo-issues',
- ProjectBadgeController.parseOptions(req, 'repo-issues')
- );
- } catch (err) {
- console.error('ProjectBadgeController.getIssues:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /project/prs - Repository open pull requests count */
- static async getPrs(req: Request, res: Response) {
- try {
- const params = ProjectBadgeController.requireRepo(req, res);
- if (!params) return;
- await ProjectBadgeController.renderBadge(
- res,
- params.owner,
- params.repo,
- 'repo-prs',
- ProjectBadgeController.parseOptions(req, 'repo-prs')
- );
- } catch (err) {
- console.error('ProjectBadgeController.getPrs:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /project/contributors - Repository contributors count */
- static async getContributors(req: Request, res: Response) {
- try {
- const params = ProjectBadgeController.requireRepo(req, res);
- if (!params) return;
- await ProjectBadgeController.renderBadge(
- res,
- params.owner,
- params.repo,
- 'repo-contributors',
- ProjectBadgeController.parseOptions(req, 'repo-contributors')
- );
- } catch (err) {
- console.error('ProjectBadgeController.getContributors:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /project/size - Repository size */
- static async getSize(req: Request, res: Response) {
- try {
- const params = ProjectBadgeController.requireRepo(req, res);
- if (!params) return;
- await ProjectBadgeController.renderBadge(
- res,
- params.owner,
- params.repo,
- 'repo-size',
- ProjectBadgeController.parseOptions(req, 'repo-size')
- );
- } catch (err) {
- console.error('ProjectBadgeController.getSize:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-}
diff --git a/src/controllers/stats.ts b/src/controllers/stats.ts
deleted file mode 100644
index ddae1f0..0000000
--- a/src/controllers/stats.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import { Request, Response } from 'express';
-import { GitHubClient } from '../utils/github-client.js';
-import { CardRenderer } from '../components/card-renderer.js';
-import { createLogger } from '../common/logger.js';
-import sharp from 'sharp';
-
-const logger = createLogger({ controller: 'StatsController' });
-
-export class StatsController {
- private static githubClient: GitHubClient;
- private static cache: Map;
- private static CACHE_DURATION: number;
- private static pendingRequests: Map> = new Map();
- private static pngCache: Map = new Map();
- static routeDocs = {
- requiredParams: ['username'],
- optionalParams: [
- 'theme',
- 'hide_title',
- 'hide_border',
- 'hide_rank',
- 'show_icons',
- 'avatar_mode',
- 'show_avatar',
- 'custom_title',
- 'data_border_style',
- 'data_border_frame',
- 'bgColor',
- 'borderColor',
- 'textColor',
- 'titleColor',
- 'format'
- ],
- payload: null as null,
- example: '/stats?username=pphatdev&theme=dark'
- };
-
- static initialize(githubClient: GitHubClient, cache: Map, cacheDuration: number) {
- this.githubClient = githubClient;
- this.cache = cache;
- this.CACHE_DURATION = cacheDuration;
- }
-
- static async getSvg(req: Request, res: Response) {
- const startTime = Date.now();
- const timings: { [key: string]: number } = {};
-
- try {
- const {
- username,
- theme = 'default',
- hide_title,
- hide_border,
- hide_rank,
- show_icons,
- avatar_mode = 'none',
- show_avatar,
- custom_title,
- data_border_style = 'solid',
- data_border_frame = 'out',
- bgColor,
- borderColor,
- textColor,
- titleColor,
- format
- } = req.query;
-
- if (!username || typeof username !== 'string') {
- return res.status(400).send('Username is required');
- }
-
- // Backward compatibility: convert show_avatar to avatar_mode
- let finalAvatarMode = avatar_mode as string;
- if (show_avatar === 'true') {
- finalAvatarMode = 'avatar';
- }
-
- const userAgent = req.get('user-agent') || '';
- const isPreviewBot = /discordbot|twitterbot|slackbot|facebookexternalhit|linkedinbot|telegrambot|telegram|mastodon|whatsapp/i.test(userAgent);
- const normalizedFormat = typeof format === 'string' ? format.toLowerCase() : (isPreviewBot ? 'webp' : 'svg');
- const wantsWebp = normalizedFormat === 'webp';
-
- // Generate optimized cache key using pipe separator
- const cacheKey = [
- username,
- theme || 'default',
- hide_title || 'false',
- hide_border || 'false',
- hide_rank || 'false',
- show_icons !== 'false' ? 'true' : 'false',
- finalAvatarMode,
- custom_title || '',
- data_border_style || 'solid',
- data_border_frame || 'out',
- bgColor || '',
- borderColor || '',
- textColor || '',
- titleColor || ''
- ].join('|');
-
- const getSvgCard = async () => {
- const cached = StatsController.cache.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < StatsController.CACHE_DURATION) {
- return cached.data;
- }
-
- if (StatsController.pendingRequests.has(cacheKey)) {
- return StatsController.pendingRequests.get(cacheKey)!;
- }
-
- const cardPromise = (async () => {
- const apiStartTime = Date.now();
- const stats = await StatsController.githubClient.fetchUserStats(username, {
- avatarMode: finalAvatarMode as 'none' | 'avatar' | 'radar'
- });
- timings['github_api'] = Date.now() - apiStartTime;
-
- const renderStartTime = Date.now();
- const card = CardRenderer.generateStatsCard(stats, {
- theme: theme as string,
- hideTitle: hide_title === 'true',
- hideBorder: hide_border === 'true',
- hideRank: hide_rank === 'true',
- showIcons: show_icons !== 'false',
- avatarMode: finalAvatarMode as 'none' | 'avatar' | 'radar',
- customTitle: custom_title as string | undefined,
- dataBorderStyle: data_border_style as 'solid' | 'frame',
- dataBorderFramePosition: data_border_frame as 'in' | 'out',
- bgColor: bgColor as string | undefined,
- borderColor: borderColor as string | undefined,
- textColor: textColor as string | undefined,
- titleColor: titleColor as string | undefined,
- });
- timings['svg_render'] = Date.now() - renderStartTime;
-
- StatsController.cache.set(cacheKey, { data: card, timestamp: Date.now() });
- return card;
- })();
-
- StatsController.pendingRequests.set(cacheKey, cardPromise);
-
- try {
- return await cardPromise;
- } finally {
- StatsController.pendingRequests.delete(cacheKey);
- }
- };
-
- if (wantsWebp) {
- const webpCacheKey = `${cacheKey}|webp`;
- const cachedWebp = StatsController.pngCache.get(webpCacheKey);
- if (cachedWebp && Date.now() - cachedWebp.timestamp < StatsController.CACHE_DURATION) {
- timings['total'] = Date.now() - startTime;
- res.setHeader('X-Timing', JSON.stringify(timings));
- res.setHeader('Content-Type', 'image/webp');
- res.setHeader('Cache-Control', 'public, max-age=600');
- return res.send(cachedWebp.data);
- }
-
- const svgCard = await getSvgCard();
-
- const webpStartTime = Date.now();
- // Sharp uses native C++ bindings for efficient SVGโWebP conversion
- const webpBuffer = await sharp(Buffer.from(svgCard))
- .webp({ quality: 75, effort: 4, alphaQuality: 100 })
- .toBuffer();
- timings['webp_convert'] = Date.now() - webpStartTime;
-
- StatsController.pngCache.set(webpCacheKey, { data: webpBuffer, timestamp: Date.now() });
- timings['total'] = Date.now() - startTime;
- res.setHeader('X-Timing', JSON.stringify(timings));
- res.setHeader('Content-Type', 'image/webp');
- res.setHeader('Cache-Control', 'public, max-age=600');
- return res.send(webpBuffer);
- }
-
- const card = await getSvgCard();
- timings['total'] = Date.now() - startTime;
- res.setHeader('X-Timing', JSON.stringify(timings));
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.send(card);
- } catch (error) {
- timings['total'] = Date.now() - startTime;
- logger.error('Error generating stats', error as Error, { timings });
- res.status(500).send(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
- }
- }
-
-}
diff --git a/src/controllers/user-badge.controller.ts b/src/controllers/user-badge.controller.ts
deleted file mode 100644
index 1cf6932..0000000
--- a/src/controllers/user-badge.controller.ts
+++ /dev/null
@@ -1,539 +0,0 @@
-/**
- * User Badge Controller
- * Handles user-specific badge endpoints (require username parameter)
- * Features: Redis persistent caching with intelligent TTL, request deduplication
- */
-import crypto from 'node:crypto';
-import { Request, Response } from 'express';
-import { db } from '../db/index.js';
-import { badges, visitorLogs } from '../db/schema.js';
-import { sql, eq } from 'drizzle-orm';
-import { GitHubClient } from '../utils/github-client.js';
-import { BadgeRenderer } from '../components/badge-renderer.js';
-import { getBadgeCacheServiceSync } from '../services/badge-cache.service.js';
-import type { BadgeOptions, UserBadgeType, BadgeRouteDoc } from '../types/badge.types.js';
-
-/** User badge types that have corresponding database columns (excludes 'visitors') */
-type StoredUserBadgeType = Exclude;
-
-/** Maps a user-based BadgeType to the matching badges table column key. */
-const TYPE_TO_COLUMN: Record = {
- 'repositories': 'repositories',
- 'organization': 'organization',
- 'languages': 'languages',
- 'followers': 'followers',
- 'total-stars': 'total_stars',
- 'total-contributors': 'total_contributors',
- 'total-commits': 'total_commits',
- 'total-code-reviews': 'total_code_reviews',
- 'total-issues': 'total_issues',
- 'total-pull-requests': 'total_pull_requests',
- 'total-joined-years': 'total_joined_years',
-};
-
-const defaultOptionsParams = ['theme', 'customLabel', 'labelColor', 'labelBackground', 'iconColor', 'valueColor', 'valueBackground', 'hideFrame', 'hideIcon'] as const;
-
-export class UserBadgeController {
- private static githubClient: GitHubClient;
- private static cache: Map;
- private static CACHE_DURATION: number;
- private static pendingRequests: Map> = new Map();
-
- /** Route documentation for user badges */
- static routeDocs: Record = {
- 'visitors': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/visitors?username=pphatdev&theme=tokyo'
- },
- 'repositories': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/repositories?username=pphatdev'
- },
- 'organization': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/organization?username=pphatdev'
- },
- 'languages': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/languages?username=pphatdev'
- },
- 'followers': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/followers?username=pphatdev'
- },
- 'total-stars': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/total-stars?username=pphatdev'
- },
- 'total-contributors': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/total-contributors?username=pphatdev'
- },
- 'total-commits': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/total-commits?username=pphatdev'
- },
- 'total-code-reviews': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/total-code-reviews?username=pphatdev'
- },
- 'total-issues': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/total-issues?username=pphatdev'
- },
- 'total-pull-requests': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/total-pull-requests?username=pphatdev'
- },
- 'total-joined-years': {
- requiredParams: ['username'],
- optionalParams: defaultOptionsParams,
- payload: null,
- example: '/badge/total-joined-years?username=pphatdev'
- },
- };
-
- /**
- * Initialize the controller with dependencies
- */
- static initialize(
- githubClient: GitHubClient,
- cache: Map,
- cacheDuration: number,
- ) {
- this.githubClient = githubClient;
- this.cache = cache;
- this.CACHE_DURATION = cacheDuration;
- }
-
- /** Parse common display options from query params. */
- private static parseOptions(req: Request, type: UserBadgeType): BadgeOptions {
- const { theme, customLabel, labelColor, labelBackground, iconColor, valueColor, valueBackground, hideFrame = 'false', hideIcon = 'true' } = req.query;
- return {
- type,
- theme: typeof theme === 'string' ? theme : undefined,
- customLabel: typeof customLabel === 'string' ? customLabel : undefined,
- labelColor: typeof labelColor === 'string' ? labelColor : undefined,
- labelBackground: typeof labelBackground === 'string' ? labelBackground : undefined,
- iconColor: typeof iconColor === 'string' ? iconColor : undefined,
- valueColor: typeof valueColor === 'string' ? valueColor : undefined,
- valueBackground: typeof valueBackground === 'string' ? valueBackground : undefined,
- hideFrame: hideFrame === 'true',
- hideIcon: hideIcon === 'true',
- };
- }
-
- /** Validate username param; sends 400 and returns null on failure. */
- private static requireUsername(req: Request, res: Response): string | null {
- const { username } = req.query;
- if (!username || typeof username !== 'string') {
- res.status(400).send('username is required');
- return null;
- }
- return username;
- }
-
- /** Build a stable cache key from username, badge type, and display options. */
- private static buildCacheKey(username: string, options: BadgeOptions): string {
- return [
- 'user',
- username,
- options.type,
- options.theme ?? 'default',
- options.customLabel ?? '',
- options.labelColor ?? '',
- options.labelBackground ?? '',
- options.iconColor ?? '',
- options.valueColor ?? '',
- options.valueBackground ?? '',
- options.hideFrame ? 'hideFrame' : '',
- options.hideIcon ? 'hideIcon' : '',
- ].join('|');
- }
-
- /** Convert BadgeOptions to Record for Redis caching */
- private static optionsToRecord(options: BadgeOptions): Record {
- return {
- theme: options.theme,
- customLabel: options.customLabel,
- labelColor: options.labelColor,
- labelBackground: options.labelBackground,
- iconColor: options.iconColor,
- valueColor: options.valueColor,
- valueBackground: options.valueBackground,
- hideFrame: options.hideFrame,
- hideIcon: options.hideIcon,
- };
- }
-
- /** Render a GitHub-data badge โ Redis โ In-memory โ DB โ GitHub API cache chain. */
- private static async renderGitHubBadge(
- res: Response,
- username: string,
- type: StoredUserBadgeType,
- options: BadgeOptions,
- ) {
- const cacheKey = UserBadgeController.buildCacheKey(username, options);
- const badgeService = getBadgeCacheServiceSync();
- const optionsRecord = UserBadgeController.optionsToRecord(options);
-
- // 1. Check Redis persistent cache first
- if (badgeService?.isReady()) {
- const redisCached = await badgeService.getUserBadgeSVG(username, type, optionsRecord);
- if (redisCached) {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.setHeader('X-Cache', 'REDIS');
- return res.send(redisCached.svg);
- }
- }
-
- // 2. Check in-memory SVG cache
- const cached = UserBadgeController.cache.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < UserBadgeController.CACHE_DURATION) {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.setHeader('X-Cache', 'MEMORY');
- return res.send(cached.data);
- }
-
- // 3. Deduplicate in-flight requests for the same key
- let pending = UserBadgeController.pendingRequests.get(cacheKey);
- if (!pending) {
- pending = (async () => {
- const col = TYPE_TO_COLUMN[type];
-
- // 4. Check DB cache
- const row = await db.select().from(badges).where(eq(badges.username, username)).get();
- const isStale = !row?.updated_at || (Date.now() - row.updated_at) > UserBadgeController.CACHE_DURATION;
- const dbValue = row?.[col] as number | null | undefined;
- let dbTimestamp = row?.updated_at ?? Date.now();
-
- let value: number;
- if (!isStale && dbValue != null) {
- value = dbValue;
- } else {
- // 5. Fetch from GitHub and persist
- value = await UserBadgeController.githubClient.fetchBadgeValue(username, type);
- const result = await db
- .insert(badges)
- .values({ username, [col]: value, updated_at: Date.now() })
- .onConflictDoUpdate({
- target: badges.username,
- set: { [col]: value, updated_at: Date.now() },
- })
- .returning();
-
- // Update timestamp to latest
- if (result[0]?.updated_at) {
- dbTimestamp = result[0].updated_at;
- }
- }
-
- const svg = BadgeRenderer.generateBadge(value, options);
-
- // Cache in both layers
- UserBadgeController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
-
- // Cache in Redis with intelligent TTL
- if (badgeService?.isReady()) {
- await badgeService.setUserBadgeSVG(username, type, optionsRecord, {
- svg,
- value,
- timestamp: Date.now(),
- dbTimestamp,
- });
- }
-
- return svg;
- })();
-
- UserBadgeController.pendingRequests.set(cacheKey, pending);
- pending.finally(() => UserBadgeController.pendingRequests.delete(cacheKey));
- }
-
- const svg = await pending;
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=600');
- res.setHeader('X-Cache', 'MISS');
- return res.send(svg);
- }
-
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Badge Endpoints
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- /**
- * GET /badge/visitors โ counts unique visitors per IP per calendar day.
- *
- * The same IP can increment the counter once per day:
- * - Day 1: IP visits โ count +1
- * - Day 1 (later): Same IP visits again โ count stays same (already counted today)
- * - Day 2: Same IP visits โ count +1 (new day, allowed to count again)
- *
- * This is enforced by the unique index on (username, ip_hash, visit_date) in the visitor_logs table.
- *
- * Caching Strategy:
- * - Redis cache for rendered SVG (10 minutes)
- * - In-memory cache for rapid F5 spam (60 seconds)
- * - Short TTL ensures visitor counts update frequently
- */
- static async getVisitors(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
-
- const options = UserBadgeController.parseOptions(req, 'visitors');
- const cacheKey = UserBadgeController.buildCacheKey(username, options);
- const badgeService = getBadgeCacheServiceSync();
- const optionsRecord = UserBadgeController.optionsToRecord(options);
-
- // 1. Check Redis persistent cache first
- if (badgeService?.isReady()) {
- const redisCached = await badgeService.getUserBadgeSVG(username, 'visitors', optionsRecord);
- if (redisCached) {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=60');
- res.setHeader('X-Cache', 'REDIS');
- return res.send(redisCached.svg);
- }
- }
-
- // 2. Check in-memory SVG cache first โ prevents unnecessary DB queries for rapid refreshes
- const cached = UserBadgeController.cache.get(cacheKey);
- if (cached && Date.now() - cached.timestamp < 60_000) { // 60-second cache
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=60');
- res.setHeader('X-Cache', 'MEMORY');
- return res.send(cached.data);
- }
-
- // Resolve the real client IP (works behind reverse proxies)
- const rawIp = (
- (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0].trim() ||
- req.socket?.remoteAddress ||
- 'unknown'
- );
-
- // Hash the IP for privacy โ truncated SHA-256 is enough for dedup
- const ipHash = crypto
- .createHash('sha256')
- .update(rawIp)
- .digest('hex')
- .slice(0, 16);
-
- // Calendar date in UTC (YYYY-MM-DD) โ ensures same IP can count once per day
- const visitDate = new Date().toISOString().split('T')[0];
-
- // Attempt to record this unique IP+date combination
- // If this IP already visited today, onConflictDoNothing() prevents duplicate insertion
- const logInsert = await db
- .insert(visitorLogs)
- .values({ username, ip_hash: ipHash, visit_date: visitDate, created_at: Date.now() })
- .onConflictDoNothing()
- .returning();
-
- let count: number;
- let dbTimestamp = Date.now();
-
- if (logInsert.length > 0) {
- // New unique visit for today โ atomically increment the stored total
- const result = await db
- .insert(badges)
- .values({ username, visitors: 1, updated_at: Date.now() })
- .onConflictDoUpdate({
- target: badges.username,
- set: { visitors: sql`${badges.visitors} + 1`, updated_at: Date.now() },
- })
- .returning();
- count = result[0]?.visitors ?? 1;
- dbTimestamp = result[0]?.updated_at ?? Date.now();
- } else {
- // Same IP already counted today โ return the current total without incrementing
- const badge = await db
- .select({ visitors: badges.visitors, updated_at: badges.updated_at })
- .from(badges)
- .where(eq(badges.username, username))
- .get();
- count = badge?.visitors ?? 0;
- dbTimestamp = badge?.updated_at ?? Date.now();
- }
-
- const svg = BadgeRenderer.generateBadge(count, options);
-
- // Cache the SVG in both layers
- UserBadgeController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
-
- // Cache in Redis with short TTL for frequent updates
- if (badgeService?.isReady()) {
- await badgeService.setUserBadgeSVG(username, 'visitors', optionsRecord, {
- svg,
- value: count,
- timestamp: Date.now(),
- dbTimestamp,
- }, 60); // 60 seconds for visitors (shorter for freshness)
- }
-
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'public, max-age=60');
- res.setHeader('X-Cache', 'MISS');
- return res.send(svg);
- } catch (err) {
- console.error('UserBadgeController.getVisitors:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/repositories */
- static async getRepositories(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'repositories', UserBadgeController.parseOptions(req, 'repositories'));
- } catch (err) {
- console.error('UserBadgeController.getRepositories:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/organization */
- static async getOrganization(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'organization', UserBadgeController.parseOptions(req, 'organization'));
- } catch (err) {
- console.error('UserBadgeController.getOrganization:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/languages */
- static async getLanguages(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'languages', UserBadgeController.parseOptions(req, 'languages'));
- } catch (err) {
- console.error('UserBadgeController.getLanguages:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/followers */
- static async getFollowers(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'followers', UserBadgeController.parseOptions(req, 'followers'));
- } catch (err) {
- console.error('UserBadgeController.getFollowers:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-stars */
- static async getTotalStars(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'total-stars', UserBadgeController.parseOptions(req, 'total-stars'));
- } catch (err) {
- console.error('UserBadgeController.getTotalStars:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-contributors */
- static async getTotalContributors(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'total-contributors', UserBadgeController.parseOptions(req, 'total-contributors'));
- } catch (err) {
- console.error('UserBadgeController.getTotalContributors:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-commits */
- static async getTotalCommits(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'total-commits', UserBadgeController.parseOptions(req, 'total-commits'));
- } catch (err) {
- console.error('UserBadgeController.getTotalCommits:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-code-reviews */
- static async getTotalCodeReviews(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'total-code-reviews', UserBadgeController.parseOptions(req, 'total-code-reviews'));
- } catch (err) {
- console.error('UserBadgeController.getTotalCodeReviews:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-issues */
- static async getTotalIssues(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'total-issues', UserBadgeController.parseOptions(req, 'total-issues'));
- } catch (err) {
- console.error('UserBadgeController.getTotalIssues:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-pull-requests */
- static async getTotalPullRequests(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'total-pull-requests', UserBadgeController.parseOptions(req, 'total-pull-requests'));
- } catch (err) {
- console.error('UserBadgeController.getTotalPullRequests:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-
- /** GET /badge/total-joined-years */
- static async getTotalJoinedYears(req: Request, res: Response) {
- try {
- const username = UserBadgeController.requireUsername(req, res);
- if (!username) return;
- await UserBadgeController.renderGitHubBadge(res, username, 'total-joined-years', UserBadgeController.parseOptions(req, 'total-joined-years'));
- } catch (err) {
- console.error('UserBadgeController.getTotalJoinedYears:', err);
- res.status(500).send(`Error: ${err instanceof Error ? err.message : err}`);
- }
- }
-}
diff --git a/src/db/index.ts b/src/db/index.ts
index 2dbe89b..dace77c 100644
--- a/src/db/index.ts
+++ b/src/db/index.ts
@@ -1,39 +1,47 @@
-import { drizzle } from 'drizzle-orm/better-sqlite3';
-import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
-import Database from 'better-sqlite3';
+import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema.js';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import fs from 'fs';
+import type { D1Database } from '@cloudflare/workers-types';
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
+type DrizzleD1 = ReturnType>;
-// Ensure the data directory exists
-const dataDir = path.join(__dirname, '../../data');
-if (!fs.existsSync(dataDir)) {
- fs.mkdirSync(dataDir, { recursive: true });
-}
-
-const dbPath = path.join(__dirname, '../../data/stats.db');
-const migrationsFolder = path.join(__dirname, '../../drizzle');
-
-const sqlite = new Database(dbPath);
-// Enable WAL mode for better concurrent access
-sqlite.pragma('journal_mode = WAL');
-sqlite.pragma('busy_timeout = 5000'); // Wait up to 5 seconds if database is locked
-export const db = drizzle(sqlite, { schema });
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let _instance: any = null;
-// Run all pending Drizzle migrations on startup
-migrate(db, { migrationsFolder });
+/**
+ * Initialize database with a Cloudflare D1 binding.
+ * Call this at the start of every Worker fetch handler before routing.
+ *
+ * import { initializeD1 } from './db/index.js';
+ * export default { async fetch(req, env) { initializeD1(env.DB); ... } }
+ */
+export function initializeD1(d1: D1Database): void {
+ _instance = drizzle(d1, { schema });
+}
-// Migrate legacy visitors table into badges (no-op if already done or table absent)
-const legacyExists = sqlite
- .prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='visitors'`)
- .get();
-if (legacyExists) {
- sqlite.exec(`
- INSERT OR IGNORE INTO badges (username, visitors)
- SELECT username, count FROM visitors;
- `);
+/**
+ * Inject an existing Drizzle instance from outside (Node.js / local-dev path).
+ * Called from src/config/db.ts after SQLite initialisation so the same
+ * `import { db }` proxy works for both deployment targets.
+ */
+export function setDb(instance: DrizzleD1): void {
+ _instance = instance;
}
+
+/**
+ * Transparent proxy over the active Drizzle instance.
+ * All existing `import { db } from '../../db/index.js'` calls continue to work
+ * without any changes in the service or controller files.
+ */
+export const db = new Proxy({} as DrizzleD1, {
+ get(_, prop: string | symbol) {
+ if (!_instance) {
+ throw new Error(
+ 'Database not initialized. ' +
+ 'Call initializeD1(env.DB) in the Worker fetch handler, ' +
+ 'or initializeDatabase() from src/shared/config/db.ts for the Node.js path.',
+ );
+ }
+ const val = (_instance as Record)[prop];
+ return typeof val === 'function' ? (val as Function).bind(_instance) : val;
+ },
+});
diff --git a/src/db/pool.ts b/src/db/pool.ts
index e2e7953..0655f64 100644
--- a/src/db/pool.ts
+++ b/src/db/pool.ts
@@ -5,7 +5,7 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
-import { createLogger } from '../common/logger.js';
+import { createLogger } from '../shared/logs/logger.js';
import path from 'path';
import { fileURLToPath } from 'url';
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 81a2629..caa42e0 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,4 +1,21 @@
-import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
+import { sqliteTable, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core";
+
+export const statsRequests = sqliteTable(
+ "stats_requests",
+ {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ username: text("username").notNull(),
+ url: text("url").notNull(),
+ user_agent: text("user_agent"),
+ created_at: integer("created_at"),
+ },
+ (table) => {
+ return {
+ ixStatsRequestUrl: index("ix_stats_request_url").on(table.url),
+ ixStatsRequestUsername: index("ix_stats_request_username").on(table.username),
+ };
+ },
+);
export const visitorLogs = sqliteTable(
"visitor_logs",
diff --git a/src/index.refactored.ts b/src/index.refactored.ts
deleted file mode 100644
index 3973a30..0000000
--- a/src/index.refactored.ts
+++ /dev/null
@@ -1,284 +0,0 @@
-/**
- * Application Entry Point (Refactored)
- * Initializes and starts the GitHub Stats API server
- *
- * Architecture follows clean code principles with:
- * - Dependency injection
- * - Service layer abstraction
- * - Centralized error handling
- * - Structured logging
- * - Health checks and monitoring
- */
-
-import 'dotenv/config';
-import express, { Application } from 'express';
-import cors from 'cors';
-import path from 'path';
-import { fileURLToPath } from 'url';
-
-// Config and Common
-import { getConfig } from './config/index.js';
-import { createLogger } from './common/logger.js';
-
-// Services
-import { GitHubService } from './services/github.service.js';
-import { MemoryCacheService, HybridCacheService } from './services/cache.service.js';
-import { getServiceContainer } from './services/base.js';
-
-// Middleware
-import { errorHandler, notFoundHandler, requestLogger, asyncHandler } from './middleware/error.middleware.js';
-
-// Controllers
-import { StatsController } from './controllers/stats.js';
-import { LanguageController } from './controllers/languages.js';
-import { GraphController } from './controllers/graph.js';
-import { UserBadgeController } from './controllers/user-badge.controller.js';
-import { ProjectBadgeController } from './controllers/project-badge.controller.js';
-import {
- healthCheck,
- livenessProbe,
- readinessProbe,
- metrics,
- initializeHealthCheck
-} from './controllers/health.controller.js';
-
-// Routes
-import { registerCachedRoutes } from './routes/redis-cached.routes.js';
-import { registerUserBadgeRoutes } from './routes/user-badge.routes.js';
-import { registerProjectBadgeRoutes } from './routes/project-badge.routes.js';
-
-// Initialize logger
-const logger = createLogger({ service: 'Application' });
-
-// Get configuration
-const config = getConfig();
-
-// Directory setup
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const publicDir = path.join(__dirname, '..', 'public');
-
-/**
- * Initialize services
- */
-async function initializeServices() {
- logger.info('Initializing services...');
-
- const container = getServiceContainer();
-
- // Initialize cache service (hybrid: Redis with memory fallback)
- const memoryCache = new MemoryCacheService();
- const cacheService = new HybridCacheService(memoryCache);
-
- try {
- await cacheService.initialize();
- container.register('cache', cacheService);
- logger.info('Cache service initialized', {
- type: cacheService.isUsingRedis() ? 'Redis' : 'Memory'
- });
- } catch (error) {
- logger.warn('Failed to initialize hybrid cache, using memory only', {
- error: (error as Error).message
- });
- container.register('cache', memoryCache);
- }
-
- // Initialize GitHub service
- const githubService = new GitHubService(config.github.token);
- container.register('github', githubService);
- logger.info('GitHub service initialized', {
- hasToken: !!config.github.token
- });
-
- // Initialize health check with cache service
- const cache = container.get('cache');
- initializeHealthCheck(cache);
-
- return container;
-}
-
-/**
- * Setup Express application
- */
-function setupApplication(container: ReturnType): Application {
- const app = express();
-
- // Basic middleware
- app.use(cors());
- app.use(express.json());
- app.use(express.urlencoded({ extended: true }));
-
- // Request logging
- app.use(requestLogger(logger));
-
- // Static files
- app.use(express.static(publicDir));
- app.use('/public', express.static(publicDir));
-
- // Health check endpoints
- app.get('/health', asyncHandler(healthCheck));
- app.get('/health/live', livenessProbe);
- app.get('/health/ready', asyncHandler(readinessProbe));
- app.get('/metrics', metrics);
-
- // API documentation endpoint
- app.get('/', (req, res) => {
- const routes = [
- { method: 'GET', path: '/health', description: 'Health check endpoint' },
- { method: 'GET', path: '/health/live', description: 'Liveness probe' },
- { method: 'GET', path: '/health/ready', description: 'Readiness probe' },
- { method: 'GET', path: '/metrics', description: 'Application metrics' },
- { method: 'GET', path: '/stats', description: 'User statistics card' },
- { method: 'GET', path: '/languages', description: 'User languages card' },
- { method: 'GET', path: '/graph', description: 'User contribution graph' },
- { method: 'GET', path: '/badge/*', description: 'User badges' },
- ];
-
- res.json({
- name: 'GitHub Stats API',
- version: '2.0.0',
- description: 'Generate dynamic GitHub stats cards and badges',
- environment: config.server.env,
- routes,
- documentation: '/api-docs',
- examples: {
- stats: `${config.server.protocol}://localhost:${config.server.port}/stats?username=pphatdev&theme=dark`,
- languages: `${config.server.protocol}://localhost:${config.server.port}/languages?username=pphatdev`,
- badge: `${config.server.protocol}://localhost:${config.server.port}/badge/total-stars?username=pphatdev`,
- },
- });
- });
-
- // Initialize controllers (legacy initialization - TODO: refactor to use DI)
- const githubService = container.get('github');
- const legacyCache = new Map();
-
- StatsController.initialize(githubService as any, legacyCache, config.cache.duration);
- LanguageController.initialize(githubService as any, legacyCache, config.cache.duration);
- GraphController.initialize(githubService as any, legacyCache, config.cache.duration);
- UserBadgeController.initialize(githubService as any, legacyCache, config.cache.duration);
- ProjectBadgeController.initialize(githubService as any, legacyCache, config.cache.duration);
-
- // Register API routes
- registerCachedRoutes(app);
- registerUserBadgeRoutes(app);
- registerProjectBadgeRoutes(app);
-
- // Error handling
- app.use(notFoundHandler);
- app.use(errorHandler(logger));
-
- return app;
-}
-
-/**
- * Start the server
- */
-async function startServer(app: Application, container: ReturnType) {
- const { server: { port, protocol, env } } = config;
-
- const httpServer = app.listen(port, () => {
- logger.info('Server started', {
- port,
- protocol,
- environment: env,
- url: `${protocol}://localhost:${port}`,
- });
-
- // Log service status
- const cacheService = container.get('cache');
- logger.info('Service status', {
- cache: cacheService.isUsingRedis ? cacheService.isUsingRedis() ? 'Redis' : 'Memory' : 'Memory',
- github: config.github.token ? 'Authenticated' : 'Unauthenticated (Rate Limited)',
- });
-
- // Show examples
- logger.info('Example endpoints', {
- stats: `${protocol}://localhost:${port}/stats?username=pphatdev&theme=dark`,
- health: `${protocol}://localhost:${port}/health`,
- });
- });
-
- // Graceful shutdown
- setupGracefulShutdown(httpServer, container);
-
- return httpServer;
-}
-
-/**
- * Setup graceful shutdown handlers
- */
-function setupGracefulShutdown(server: any, container: ReturnType) {
- const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT'];
-
- signals.forEach(signal => {
- process.on(signal, async () => {
- logger.info(`Received ${signal}, starting graceful shutdown...`);
-
- // Stop accepting new connections
- server.close(async () => {
- logger.info('HTTP server closed');
-
- try {
- // Cleanup services
- const cacheService = container.get('cache');
- if (cacheService.disconnect) {
- await cacheService.disconnect();
- logger.info('Cache service disconnected');
- }
-
- logger.info('Graceful shutdown completed');
- process.exit(0);
- } catch (error) {
- logger.error('Error during shutdown', error as Error);
- process.exit(1);
- }
- });
-
- // Force shutdown after timeout
- setTimeout(() => {
- logger.error('Forced shutdown after timeout');
- process.exit(1);
- }, 10000); // 10 second timeout
- });
- });
-
- // Handle uncaught errors
- process.on('uncaughtException', (error) => {
- logger.error('Uncaught exception', error);
- process.exit(1);
- });
-
- process.on('unhandledRejection', (reason, promise) => {
- logger.error('Unhandled rejection', reason as Error, { promise });
- process.exit(1);
- });
-}
-
-/**
- * Main application bootstrap
- */
-async function bootstrap() {
- try {
- logger.info('Starting GitHub Stats API...', {
- nodeVersion: process.version,
- environment: config.server.env,
- });
-
- // Initialize services
- const container = await initializeServices();
-
- // Setup application
- const app = setupApplication(container);
-
- // Start server
- await startServer(app, container);
-
- } catch (error) {
- logger.error('Failed to start application', error as Error);
- process.exit(1);
- }
-}
-
-// Start the application
-bootstrap();
diff --git a/src/index.ts b/src/index.ts
index 6ab1c71..ece5031 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,124 +1,12 @@
-import 'dotenv/config';
-import { getGlobalErrorHandlers, setupGracefulShutdown } from './utils/global-error.js';
+/**
+ * Main Entry Point
+ * Starts the application using the modular architecture
+ */
-// Add global error handlers IMMEDIATELY
-getGlobalErrorHandlers();
+import { startServer } from './server.js';
-import express from 'express';
-import cors from 'cors';
-import compression from 'compression';
-import { GitHubClient } from './utils/github-client.js';
-import { getRedisClient } from './utils/redis-client.js';
-import { getBadgeCacheService } from './services/badge-cache.service.js';
-import { warmupRedisCache } from './routes/redis-cached.routes.js';
-import { initializeControllers, registerRoutes } from './routes/register.routes.js';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import cluster from 'cluster';
-import { getRoutes } from './routes/docs.routes.js';
-
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const publicDir = path.join(__dirname, '..', 'public');
-
-const app = express();
-
-// โก๏ธ PERFORMANCE: Enable gzip compression for responses
-app.use(compression({ level: 6, threshold: 1024, }));
-
-// ๐ SECURITY: Manual security headers
-app.use((req, res, next) => {
- res.setHeader('X-Content-Type-Options', 'nosniff');
- next();
-});
-
-// Standard middleware
-app.use(cors());
-app.use(express.json({ limit: '10mb' }));
-app.use(express.urlencoded({ extended: true }));
-app.use(express.static(publicDir));
-app.use('/public', express.static(publicDir));
-
-const staticRoots = ['/', '/public'];
-
-app.get('/', (_req, res) => {
- res.json({
- routes: getRoutes(app),
- staticAssets: { roots: staticRoots, example: '/sitemap.xml' }
- });
-});
-
-const PORT = process.env.PORT || 3000;
-const APP_ENV = process.env.APP_ENV || 'development';
-const PROTOCOL = APP_ENV === 'production' ? 'https' : 'http';
-const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
-
-// Only log warnings from worker 1 or non-cluster mode
-const shouldLog = !cluster.isWorker || cluster.worker?.id === 1;
-
-if (!GITHUB_TOKEN && shouldLog) {
- console.warn('โ ๏ธ WARNING: GITHUB_TOKEN is not set!');
- console.warn('โ ๏ธ You will hit rate limits without authentication.');
- console.warn('โ ๏ธ Create a .env file with: GITHUB_TOKEN=your_token_here');
- console.warn('โ ๏ธ Get a token at: https://github.com/settings/tokens');
-}
-
-// Initialize Redis (optional - falls back gracefully if not available)
-let redis_initialized = false;
-let badgeCache_initialized = false;
-
-(async () => {
- try {
- await getRedisClient();
- redis_initialized = true;
- if (shouldLog) console.log('โ
Redis cache initialized');
- } catch (error) {
- if (shouldLog) {
- console.warn('โ ๏ธ Redis not available. Running with in-memory cache only.');
- console.warn('โ ๏ธ To enable Redis: REDIS_URL=redis://localhost:6379');
- }
- }
-
- // Initialize badge cache service (uses Redis if available)
- try {
- await getBadgeCacheService();
- badgeCache_initialized = true;
- if (shouldLog) console.log('โ
Persistent badge cache initialized');
- } catch (error) {
- if (shouldLog) {
- console.warn('โ ๏ธ Badge cache initialization failed. Using in-memory only.');
- }
- }
-})();
-
-const githubClient = new GitHubClient(GITHUB_TOKEN);
-
-// Cache to reduce API calls
-const cache = new Map();
-// 2 hours (increased from 20 minutes for better hit rate)
-const CACHE_DURATION = 2 * 60 * 60 * 1000;
-
-// Initialize controllers and register routes
-initializeControllers(githubClient, cache, CACHE_DURATION);
-registerRoutes(app);
-
-app.listen(PORT, () => {
- if (shouldLog) {
- const workerId = cluster.isWorker ? ` (Worker ${cluster.worker?.id})` : '';
- console.log(`๐ GitHub Stats server running on ${PROTOCOL}://localhost:${PORT}${workerId}`);
- console.log(`๐ Example: ${PROTOCOL}://localhost:${PORT}/stats?username=pphatdev`);
- console.log(`๐ง Environment: ${APP_ENV}`);
- console.log(`๐พ Cache: Redis ${redis_initialized ? 'โ
' : 'โ ๏ธ'} | Badges ${badgeCache_initialized ? 'โ
' : 'โ ๏ธ'}`);
- }
-
- const warmupUsername = process.env.WARMUP_USERNAME;
- if (warmupUsername && redis_initialized && shouldLog) {
- warmupRedisCache(warmupUsername, PORT, PROTOCOL).catch((error) => {
- console.warn('โ ๏ธ Redis warm-up failed:', error);
- });
- }
+// Start the server
+startServer().catch((error) => {
+ console.error('Failed to start server:', error);
+ process.exit(1);
});
-
-// Graceful shutdown
-setupGracefulShutdown();
\ No newline at end of file
diff --git a/src/modules/badges/badges.controller.ts b/src/modules/badges/badges.controller.ts
new file mode 100644
index 0000000..0ed7d2e
--- /dev/null
+++ b/src/modules/badges/badges.controller.ts
@@ -0,0 +1,385 @@
+/**
+ * Badges Controller
+ * Handles the unified query-style badge API.
+ */
+
+import { Request, Response } from 'express';
+import { BadgesService } from './badges.service.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { BadgeQuery } from '../../shared/validations/validation.js';
+import { hashClientIp } from '../../shared/utils/visitor.js';
+import { normalizeBadgeThemeName } from '../../shared/utils/themes.js';
+import type {
+ BadgeEffect,
+ BadgeName,
+ BadgeOptions,
+ BadgeSize,
+ ProjectBadgeType,
+ UserBadgeType,
+} from './badges.types.js';
+
+const logger = createLogger({ controller: 'BadgesController' });
+
+const USER_BADGE_TYPES: readonly UserBadgeType[] = [
+ 'visitors',
+ 'repositories',
+ 'organization',
+ 'languages',
+ 'followers',
+ 'total-stars',
+ 'total-contributors',
+ 'total-commits',
+ 'total-code-reviews',
+ 'total-issues',
+ 'total-pull-requests',
+ 'total-joined-years',
+] as const;
+
+const PROJECT_BADGE_TYPES: readonly ProjectBadgeType[] = [
+ 'stars',
+ 'forks',
+ 'contributors',
+ 'issues',
+ 'pull-requests',
+ 'watchers',
+ 'size',
+] as const;
+
+const PROJECT_RENDERER_TYPES: Record = {
+ stars: 'repo-stars',
+ forks: 'repo-forks',
+ contributors: 'repo-contributors',
+ issues: 'repo-issues',
+ 'pull-requests': 'repo-prs',
+ watchers: 'repo-watchers',
+ size: 'repo-size',
+ commits: 'repo-size',
+ 'code-reviews': 'repo-size',
+ language: 'repo-size',
+ license: 'repo-size',
+};
+
+const SUPPORTED_BADGE_NAMES: readonly BadgeName[] = [
+ ...USER_BADGE_TYPES,
+ ...PROJECT_BADGE_TYPES,
+] as const;
+
+export class BadgesController {
+ constructor(private readonly badgesService: BadgesService) {}
+
+ async getBadges(req: Request, res: Response): Promise {
+ const startTime = Date.now();
+
+ try {
+ // `validate(badgeQuerySchema, 'query')` already enforced username shape,
+ // enum values (effect/size), integer ranges (column/p), and hex colors
+ // (normalized to `#โฆ`). CSV fields (`name`, `theme`) are still validated
+ // as strings โ this controller splits and per-item validates them so
+ // that the discovery payload keeps its precise error messages.
+ const v = (req as Request & { validated?: BadgeQuery }).validated ?? ({} as BadgeQuery);
+ const username = v.username as string;
+
+ const rawNames = this.parseCsv(v.name);
+ if (rawNames.length === 0) {
+ res.json(this.getDiscoveryPayload());
+ return;
+ }
+
+ const invalidNames = rawNames.filter((name) => !this.isBadgeName(name));
+ if (invalidNames.length > 0) {
+ res.status(400).json({
+ error: 'Invalid badge name',
+ invalidNames,
+ supported: this.getSupportedNames(),
+ });
+ return;
+ }
+
+ const names = rawNames as BadgeName[];
+
+ const repo = v.repo?.trim() || undefined;
+ const projectNames = names.filter((name): name is ProjectBadgeType => this.isProjectBadgeType(name));
+ if (projectNames.length > 0 && !repo) {
+ res.status(400).json({
+ error: 'repo is required for repository badges',
+ namesRequiringRepo: projectNames,
+ });
+ return;
+ }
+
+ // Canonicalise each theme once at the boundary so aliased spellings
+ // (`Ocean` vs `ocean`) don't split the LRU into two entries per
+ // badge (M1). Zod refuses unknown names upstream, so every entry
+ // here is expected to normalise cleanly.
+ const themes = this.parseCsv(v.theme).map(normalizeBadgeThemeName);
+ const effect = v.effect;
+ const size: BadgeSize = v.size ?? 'small';
+ const column = v.column ?? 50;
+
+ const baseOptions: BadgeOptions = {
+ customLabel: v.customLabel,
+ labelColor: v.labelColor,
+ labelBackground: v.labelBackground,
+ iconColor: v.iconColor,
+ valueColor: v.valueColor,
+ valueBackground: v.valueBackground,
+ hideFrame: v.hideFrame === 'true',
+ realtime: v.realtime === 'true',
+ padding: v.p ?? 0,
+ };
+
+ // Compute the visitor IP hash once per request. Only relevant if
+ // the caller asked for a `visitors` badge; safe to always compute.
+ const ipHash = hashClientIp(req.ip);
+
+ const badges = await Promise.all(
+ names.map((name, index) => this.generateBadge(name, username, repo, {
+ ...baseOptions,
+ theme: this.resolveTheme(themes, index),
+ customType: this.resolveRendererType(name),
+ }, ipHash)),
+ );
+
+ const hasVisitorsBadge = names.includes('visitors');
+
+ const svg = this.combineBadges(badges, { column, effect, size, padding: baseOptions.padding || 0 });
+
+ const duration = Date.now() - startTime;
+ logger.info('Unified badges generated', {
+ username,
+ repo,
+ names,
+ effect,
+ size,
+ column,
+ duration,
+ });
+
+ res.setHeader('Content-Type', 'image/svg+xml');
+ if (hasVisitorsBadge) {
+ // Visitors must not be cached at CDN/edge; every request should hit origin to increment the counter.
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+ res.setHeader('Pragma', 'no-cache');
+ res.setHeader('Expires', '0');
+ res.setHeader('Surrogate-Control', 'no-store');
+ } else {
+ res.setHeader('Cache-Control', this.badgesService.getCacheControl());
+ }
+ res.send(svg);
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ logger.error('Failed to generate unified badges', error as Error, { duration });
+ res.status(500).json({ error: 'Failed to generate badges' });
+ }
+ }
+
+ private async generateBadge(
+ name: BadgeName,
+ username: string,
+ repo: string | undefined,
+ options: BadgeOptions,
+ ipHash: string,
+ ): Promise {
+ if (this.isUserBadgeType(name)) {
+ return this.badgesService.generateUserBadge(username, name, options, repo, ipHash);
+ }
+
+ const projectTarget = this.resolveProjectTarget(username, repo!);
+ return this.badgesService.generateProjectBadge(projectTarget.owner, projectTarget.repo, name, options);
+ }
+
+ private resolveProjectTarget(defaultOwner: string, repoParam: string): { owner: string; repo: string } {
+ const normalized = repoParam.trim().replace(/^\/+|\/+$/g, '');
+ const parts = normalized.split('/').filter(Boolean);
+
+ // Support both:
+ // - repo=my-repo => owner defaults to username
+ // - repo=owner/my-repo => owner/repo from query
+ if (parts.length >= 2) {
+ return {
+ owner: parts[0],
+ repo: parts.slice(1).join('/'),
+ };
+ }
+
+ return {
+ owner: defaultOwner,
+ repo: normalized,
+ };
+ }
+
+ private getDiscoveryPayload() {
+ return {
+ route: this.getRoutePattern(),
+ required: ['username'],
+ optional: ['repo', 'name', 'theme', 'effect', 'column', 'size', 'p'],
+ supported: this.getSupportedNames(),
+ examples: [
+ '/badges?username=pphatdev&name=visitors,total-stars',
+ '/badges?username=pphatdev&name=visitors,total-stars,repositories&theme=ocean,aurora&effect=wave&column=2&size=medium',
+ '/badges?username=pphatdev&repo=github-stats&name=visitors,stars,forks&theme=galaxy,ocean&effect=glow&column=3&size=large',
+ ],
+ };
+ }
+
+ private getRoutePattern(): string {
+ return '/badges?username={username}&repo={repo}&name={visitors,total-stars,...}&theme={theme1,theme2,...}&effect={wave|glow}&column={1-50}&size={small|medium|large}&p={0-100}';
+ }
+
+ private getSupportedNames() {
+ return {
+ user: USER_BADGE_TYPES,
+ repo: PROJECT_BADGE_TYPES,
+ };
+ }
+
+ private parseCsv(value: unknown): string[] {
+ if (typeof value !== 'string') {
+ return [];
+ }
+
+ return value
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+ }
+
+ private resolveTheme(themes: string[], index: number): string | undefined {
+ if (themes.length === 0) {
+ return 'default';
+ }
+
+ return themes[index % themes.length];
+ }
+
+ private isUserBadgeType(value: string): value is UserBadgeType {
+ return (USER_BADGE_TYPES as readonly string[]).includes(value);
+ }
+
+ private isProjectBadgeType(value: string): value is ProjectBadgeType {
+ return (PROJECT_BADGE_TYPES as readonly string[]).includes(value);
+ }
+
+ private isBadgeName(value: string): value is BadgeName {
+ return (SUPPORTED_BADGE_NAMES as readonly string[]).includes(value);
+ }
+
+ private resolveRendererType(name: BadgeName): string {
+ if (this.isUserBadgeType(name)) {
+ return name;
+ }
+
+ return PROJECT_RENDERER_TYPES[name];
+ }
+
+ private combineBadges(
+ badges: string[],
+ options: { column: number; effect?: BadgeEffect; size: BadgeSize; padding: number },
+ ): string {
+ const scale = this.getScale(options.size);
+ const gap = this.getGap(options.size);
+ const columnCount = Math.max(1, Math.min(options.column, 50));
+ const rowCount = Math.ceil(badges.length / columnCount);
+ const actualColumnCount = Math.min(columnCount, badges.length);
+
+ const svgParts = badges.map((badge, index) => this.extractSvgParts(badge, `badge-${index}`));
+ const widths = new Array(actualColumnCount).fill(0);
+ const heights = new Array(rowCount).fill(0);
+
+ svgParts.forEach((part, index) => {
+ const row = Math.floor(index / columnCount);
+ const col = index % columnCount;
+ const scaledWidth = Math.ceil(part.width * scale);
+ const scaledHeight = Math.ceil(part.height * scale);
+
+ if (col < actualColumnCount) {
+ widths[col] = Math.max(widths[col], scaledWidth);
+ }
+ heights[row] = Math.max(heights[row], scaledHeight);
+ });
+
+ const xOffsets: number[] = [];
+ let xCursor = 0;
+ for (let i = 0; i < actualColumnCount; i++) {
+ xOffsets.push(xCursor);
+ xCursor += widths[i] + (i < actualColumnCount - 1 ? gap : 0);
+ }
+
+ const yOffsets: number[] = [];
+ let yCursor = 0;
+ for (let i = 0; i < rowCount; i++) {
+ yOffsets.push(yCursor);
+ yCursor += heights[i] + (i < rowCount - 1 ? gap : 0);
+ }
+
+ const totalWidth = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, actualColumnCount - 1) * gap + options.padding * 2;
+ const totalHeight = heights.reduce((sum, height) => sum + height, 0) + Math.max(0, rowCount - 1) * gap + options.padding * 2;
+
+ const defs = options.effect === 'glow'
+ ? ' '
+ : '';
+
+ const groups = svgParts.map((part, index) => {
+ const row = Math.floor(index / columnCount);
+ const col = index % columnCount;
+ const x = xOffsets[col] + options.padding;
+ const y = yOffsets[row] + options.padding;
+ const filterAttr = options.effect === 'glow' ? ' filter="url(#badge-stack-glow)"' : '';
+ const animatedTransform = options.effect === 'wave'
+ ? ` `
+ : '';
+ const transformAttr = options.effect === 'wave' ? '' : ` transform="translate(${x} ${y})"`;
+
+ return `${animatedTransform}${part.content} `;
+ }).join('');
+
+ return `${defs}${groups} `;
+ }
+
+ private getScale(size: BadgeSize): number {
+ switch (size) {
+ case 'small':
+ return 0.85;
+ case 'large':
+ return 1.2;
+ default:
+ return 1;
+ }
+ }
+
+ private getGap(size: BadgeSize): number {
+ switch (size) {
+ case 'small':
+ return 6;
+ case 'large':
+ return 10;
+ default:
+ return 8;
+ }
+ }
+
+ private extractSvgParts(svg: string, suffix: string): { width: number; height: number; content: string } {
+ const widthMatch = svg.match(/\bwidth="([0-9]+(?:\.[0-9]+)?)"/i);
+ const heightMatch = svg.match(/\bheight="([0-9]+(?:\.[0-9]+)?)"/i);
+ const content = svg
+ .replace(/^\s*]*>/i, '')
+ .replace(/<\/svg>\s*$/i, '');
+
+ return {
+ width: widthMatch ? Number.parseFloat(widthMatch[1]) : 120,
+ height: heightMatch ? Number.parseFloat(heightMatch[1]) : 34,
+ content: this.namespaceIds(content, suffix),
+ };
+ }
+
+ private namespaceIds(content: string, suffix: string): string {
+ const ids = Array.from(content.matchAll(/\bid="([^"]+)"/g), (match) => match[1]);
+
+ return ids.reduce((output, id) => {
+ const nextId = `${id}-${suffix}`;
+ return output
+ .split(`id="${id}"`).join(`id="${nextId}"`)
+ .split(`url(#${id})`).join(`url(#${nextId})`);
+ }, content);
+ }
+}
diff --git a/src/modules/badges/badges.routes.ts b/src/modules/badges/badges.routes.ts
new file mode 100644
index 0000000..50b239e
--- /dev/null
+++ b/src/modules/badges/badges.routes.ts
@@ -0,0 +1,30 @@
+/**
+ * Badges Routes
+ * Defines HTTP routes for the unified badges endpoint.
+ */
+
+import { Router } from 'express';
+import { BadgesController } from './badges.controller.js';
+import { BadgesService } from './badges.service.js';
+import { GitHubClient } from '../../shared/utils/github-client.js';
+import { validate } from '../../shared/middlewares/error.middleware.js';
+import { badgeQuerySchema } from '../../shared/validations/validation.js';
+import type { ResponseCache } from '../../shared/utils/response-cache.js';
+
+export function createBadgesRouter(
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number
+): Router {
+ const router = Router();
+
+ // Initialize service and controller
+ const badgesService = new BadgesService(githubClient, cache, cacheDuration);
+ const badgesController = new BadgesController(badgesService);
+
+ router.get('/', validate(badgeQuerySchema, 'query'), async (req, res) => {
+ await badgesController.getBadges(req, res);
+ });
+
+ return router;
+}
diff --git a/src/modules/badges/badges.service.ts b/src/modules/badges/badges.service.ts
new file mode 100644
index 0000000..e3f7f6c
--- /dev/null
+++ b/src/modules/badges/badges.service.ts
@@ -0,0 +1,547 @@
+/**
+ * Badges Service
+ * Business logic for badge generation
+ */
+
+import { GitHubClient } from '../../shared/utils/github-client.js';
+import { db } from '../../db/index.js';
+import { badges, visitorLogs } from '../../db/schema.js';
+import { eq, sql } from 'drizzle-orm';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { UserBadgeType, ProjectBadgeType, BadgeOptions, BadgeCache } from './badges.types.js';
+import { BadgeRenderer } from '../../shared/components/badge-renderer.js';
+import { BadgeType } from '../../shared/types/badge.types.js';
+import type { ResponseCache } from '../../shared/utils/response-cache.js';
+import { currentVisitDateUtc } from '../../shared/utils/visitor.js';
+import { GITHUB_USERNAME_RE } from '../../shared/utils/username.js';
+
+const logger = createLogger({ service: 'BadgesService' });
+
+export class BadgesService {
+ private githubClient: GitHubClient;
+ private cache: ResponseCache;
+ private pendingRequests: Map>;
+ private backgroundRefreshAt: Map;
+ private readonly cacheDuration: number;
+ private readonly realtimeMaxStaleMs = 15000;
+ private readonly minBackgroundRefreshIntervalMs = 120000;
+ private readonly minRealtimeIntervalMs = 30000;
+ private readonly HTTP_CACHE_CONTROL = 'public, max-age=600, s-maxage=1800, stale-while-revalidate=86400';
+
+ constructor(
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number
+ ) {
+ this.githubClient = githubClient;
+ this.cache = cache;
+ this.pendingRequests = new Map();
+ this.backgroundRefreshAt = new Map();
+ this.cacheDuration = cacheDuration;
+ }
+
+ /**
+ * Generate user badge
+ */
+ async generateUserBadge(
+ username: string,
+ type: UserBadgeType,
+ options: BadgeOptions = {},
+ repo?: string,
+ ipHash?: string
+ ): Promise {
+ // Visitors always bypass cache because each request may increment the counter.
+ if (type === 'visitors') {
+ return this.generateNewUserBadge(username, type, options, repo, ipHash);
+ }
+
+ const cacheKey = this.getCacheKey('user', username, type, options);
+ const now = Date.now();
+ const cached = this.cache.get(cacheKey);
+
+ // Realtime mode still has a short cooldown to protect GitHub rate limits.
+ if (options.realtime) {
+ if (cached && now - cached.timestamp < this.minRealtimeIntervalMs) {
+ logger.debug('Realtime request served from short-term cache', { username, type });
+ return cached.data;
+ }
+
+ return this.fetchAndCacheBadge(cacheKey, () => this.generateNewUserBadge(username, type, options));
+ }
+
+ // Check cache
+ if (cached && now - cached.timestamp < this.cacheDuration) {
+ const age = now - cached.timestamp;
+ if (age >= this.realtimeMaxStaleMs && this.canBackgroundRefresh(cacheKey, now)) {
+ this.refreshBadgeInBackground(cacheKey, () => this.generateNewUserBadge(username, type, options));
+ }
+ logger.debug('Returning cached user badge', { username, type });
+ return cached.data;
+ }
+
+ return this.fetchAndCacheBadge(cacheKey, () => this.generateNewUserBadge(username, type, options));
+ }
+
+ /**
+ * Generate project badge
+ */
+ async generateProjectBadge(
+ owner: string,
+ repo: string,
+ type: ProjectBadgeType,
+ options: BadgeOptions = {}
+ ): Promise {
+ const cacheKey = this.getCacheKey('project', `${owner}/${repo}`, type, options);
+ const now = Date.now();
+ const cached = this.cache.get(cacheKey);
+
+ if (options.realtime) {
+ if (cached && now - cached.timestamp < this.minRealtimeIntervalMs) {
+ logger.debug('Realtime project badge served from short-term cache', { owner, repo, type });
+ return cached.data;
+ }
+
+ return this.fetchAndCacheBadge(cacheKey, () => this.generateNewProjectBadge(owner, repo, type, options));
+ }
+
+ // Check cache
+ if (cached && now - cached.timestamp < this.cacheDuration) {
+ const age = now - cached.timestamp;
+ if (age >= this.realtimeMaxStaleMs && this.canBackgroundRefresh(cacheKey, now)) {
+ this.refreshBadgeInBackground(cacheKey, () => this.generateNewProjectBadge(owner, repo, type, options));
+ }
+ logger.debug('Returning cached project badge', { owner, repo, type });
+ return cached.data;
+ }
+
+ return this.fetchAndCacheBadge(cacheKey, () => this.generateNewProjectBadge(owner, repo, type, options));
+ }
+
+ /**
+ * Generate new user badge from GitHub data
+ */
+ private async generateNewUserBadge(
+ username: string,
+ type: UserBadgeType,
+ options: BadgeOptions,
+ repo?: string,
+ ipHash?: string
+ ): Promise {
+ let value: number;
+
+ switch (type) {
+ case 'visitors':
+ // Get from database โ scope to repo when provided
+ value = await this.getVisitorCount(username, repo, ipHash);
+ break;
+ case 'repositories':
+ case 'followers':
+ case 'organization':
+ case 'languages':
+ case 'total-stars':
+ case 'total-contributors':
+ case 'total-commits':
+ case 'total-code-reviews':
+ case 'total-issues':
+ case 'total-pull-requests':
+ case 'total-joined-years':
+ // Use GitHub source for accurate values (client has internal request cache).
+ value = await this.githubClient.fetchBadgeValue(
+ username,
+ type as Exclude
+ );
+ break;
+ default:
+ throw new Error(`Unknown user badge type: ${type}`);
+ }
+
+ return this.generateSimpleBadge(
+ options.customLabel || type,
+ value.toString(),
+ options
+ );
+ }
+
+ /**
+ * Generate new project badge from GitHub data
+ */
+ private async generateNewProjectBadge(
+ owner: string,
+ repo: string,
+ type: ProjectBadgeType,
+ options: BadgeOptions
+ ): Promise {
+ let value: number;
+
+ // Map project badge types to repo badge types
+ const repoBadgeTypeMap: Record = {
+ 'stars': 'repo-stars',
+ 'forks': 'repo-forks',
+ 'watchers': 'repo-watchers',
+ 'issues': 'repo-issues',
+ 'pull-requests': 'repo-prs',
+ 'contributors': 'repo-contributors',
+ 'size': 'repo-size'
+ };
+
+ // For simple metrics, use fetchRepoBadgeValue
+ if (repoBadgeTypeMap[type]) {
+ value = await this.githubClient.fetchRepoBadgeValue(
+ owner,
+ repo,
+ repoBadgeTypeMap[type] as any
+ );
+
+ return this.generateSimpleBadge(
+ options.customLabel || type,
+ value.toString(),
+ options
+ );
+ }
+
+ // For language and license, fetch directly (not implemented yet)
+ if (type === 'language' || type === 'license') {
+ return this.generateSimpleBadge(
+ options.customLabel || type,
+ 'N/A',
+ options
+ );
+ }
+
+ // For other metrics
+ value = await this.getProjectMetric(owner, repo, type);
+ return this.generateSimpleBadge(
+ options.customLabel || type,
+ value.toString(),
+ options
+ );
+ }
+
+ /**
+ * Generate a simple badge SVG
+ */
+ private generateSimpleBadge(label: string, value: string, options: BadgeOptions): string {
+ const numericValue = Number.parseFloat(value.replace(/[^0-9.]/g, ''));
+
+ return BadgeRenderer.generateBadge(Number.isFinite(numericValue) ? numericValue : 0, {
+ type: this.resolveRendererType(label, options),
+ theme: options.theme,
+ customLabel: options.customLabel,
+ labelColor: options.labelColor,
+ labelBackground: options.labelBackground,
+ iconColor: options.iconColor,
+ valueColor: options.valueColor,
+ valueBackground: options.valueBackground,
+ hideFrame: options.hideFrame,
+ hideIcon: true,
+ });
+ }
+
+ private resolveRendererType(label: string, options: BadgeOptions): BadgeType {
+ const customType = options.customType as BadgeType | undefined;
+ if (customType) {
+ return customType;
+ }
+
+ const normalizedLabel = label.toLowerCase();
+
+ const userTypes: BadgeType[] = [
+ 'visitors',
+ 'repositories',
+ 'organization',
+ 'languages',
+ 'followers',
+ 'total-stars',
+ 'total-contributors',
+ 'total-commits',
+ 'total-code-reviews',
+ 'total-issues',
+ 'total-pull-requests',
+ 'total-joined-years',
+ ];
+
+ const matchedUserType = userTypes.find((type) => type === normalizedLabel);
+ if (matchedUserType) {
+ return matchedUserType;
+ }
+
+ const projectTypeMap: Record = {
+ 'stars': 'repo-stars',
+ 'forks': 'repo-forks',
+ 'watchers': 'repo-watchers',
+ 'issues': 'repo-issues',
+ 'pull-requests': 'repo-prs',
+ 'contributors': 'repo-contributors',
+ 'size': 'repo-size',
+ };
+
+ return projectTypeMap[normalizedLabel] || 'visitors';
+ }
+
+ /**
+ * Get user badge value from database
+ */
+ private async getUserBadgeValue(username: string, type: UserBadgeType): Promise {
+ const result = await db.select().from(badges).where(eq(badges.username, username)).limit(1);
+
+ if (result.length === 0) {
+ // Fetch fresh data
+ await this.refreshUserBadgeData(username);
+ const refreshed = await db.select().from(badges).where(eq(badges.username, username)).limit(1);
+ return this.extractBadgeValue(refreshed[0], type);
+ }
+
+ return this.extractBadgeValue(result[0], type);
+ }
+
+ /**
+ * Extract badge value from database record
+ */
+ private extractBadgeValue(record: any, type: UserBadgeType): number {
+ const columnMap: Record = {
+ 'repositories': 'repositories',
+ 'organization': 'organization',
+ 'languages': 'languages',
+ 'followers': 'followers',
+ 'total-stars': 'total_stars',
+ 'total-contributors': 'total_contributors',
+ 'total-commits': 'total_commits',
+ 'total-code-reviews': 'total_code_reviews',
+ 'total-issues': 'total_issues',
+ 'total-pull-requests': 'total_pull_requests',
+ 'total-joined-years': 'total_joined_years',
+ };
+
+ const column = columnMap[type];
+ return record[column] || 0;
+ }
+
+ /**
+ * Refresh user badge data from GitHub
+ */
+ private async refreshUserBadgeData(username: string): Promise {
+ const userData = await this.githubClient.fetchUserStats(username, { avatarMode: 'none' });
+ const repositories = await this.githubClient.fetchBadgeValue(username, 'repositories');
+ const now = Date.now();
+
+ await db.insert(badges)
+ .values({
+ username,
+ repositories,
+ organization: 0, // Not available in GitHubStats
+ languages: 0, // Not available in GitHubStats
+ followers: 0, // Not available in GitHubStats
+ total_stars: userData.totalStars || 0,
+ total_contributors: 0, // Not available
+ total_commits: userData.totalCommits || 0,
+ total_code_reviews: 0, // Not available
+ total_issues: userData.totalIssues || 0,
+ total_pull_requests: userData.totalPRs || 0,
+ total_joined_years: 0, // Need createdAt from user data
+ updated_at: now
+ })
+ .onConflictDoUpdate({
+ target: badges.username,
+ set: {
+ repositories,
+ total_stars: userData.totalStars || 0,
+ total_commits: userData.totalCommits || 0,
+ total_issues: userData.totalIssues || 0,
+ total_pull_requests: userData.totalPRs || 0,
+ updated_at: now
+ }
+ });
+
+ logger.info('User badge data refreshed', { username });
+ }
+
+ /**
+ * Record a visit and return the resulting visitor count.
+ *
+ * Dedup rules (H4): a given `(visit_date, key, ip_hash)` triple bumps
+ * the counter at most once per UTC day. The unique index on
+ * `visitor_logs` is the source of truth; we treat an INSERT that
+ * changed no rows as "already counted today" and skip the bump.
+ *
+ * When we can't identify the caller (no ipHash โ should not happen once
+ * `trust proxy` is on; belt-and-braces here) or when the caller supplies
+ * a username that doesn't match GitHub's naming rules, we return the
+ * current count without incrementing. This is the anti-inflation stance.
+ */
+ private async getVisitorCount(username: string, repo?: string, ipHash?: string): Promise {
+ const now = Date.now();
+ const key = repo ? `${username}/${repo}` : username;
+
+ // Defense-in-depth: don't let a garbage username become a DB row even
+ // if some upstream path skipped Zod. Composite (`user/repo`) keys are
+ // allowed through because `key` is what lands in `badges.username`.
+ const usernameOk = GITHUB_USERNAME_RE.test(username);
+ if (!usernameOk) {
+ logger.warn('Visitor bump refused: invalid username shape', { username });
+ const [row] = await db.select({ visitors: badges.visitors })
+ .from(badges)
+ .where(eq(badges.username, key))
+ .limit(1);
+ return row?.visitors ?? 0;
+ }
+
+ // No IP โ we can't dedup. Refuse to increment so anonymous scrapers
+ // can't inflate the counter. Report the last known total instead.
+ if (!ipHash) {
+ const [row] = await db.select({ visitors: badges.visitors })
+ .from(badges)
+ .where(eq(badges.username, key))
+ .limit(1);
+ return row?.visitors ?? 0;
+ }
+
+ const visitDate = currentVisitDateUtc();
+
+ // SQLite's `INSERT OR IGNORE` uses the unique (username, ip_hash,
+ // visit_date) constraint on `visitor_logs`; the returning() clause
+ // yields one row on a successful insert and an empty result on a
+ // conflict-suppressed insert.
+ const inserted = await db.insert(visitorLogs)
+ .values({
+ username: key,
+ ip_hash: ipHash,
+ visit_date: visitDate,
+ created_at: now,
+ })
+ .onConflictDoNothing({
+ target: [visitorLogs.username, visitorLogs.ip_hash, visitorLogs.visit_date],
+ })
+ .returning({ id: visitorLogs.id });
+
+ if (inserted.length === 0) {
+ // Same viewer already counted today. Return the current total
+ // without touching `badges.visitors`.
+ const [row] = await db.select({ visitors: badges.visitors })
+ .from(badges)
+ .where(eq(badges.username, key))
+ .limit(1);
+ return row?.visitors ?? 0;
+ }
+
+ // First unique visit today for this viewer โ bump the counter.
+ const [result] = await db.insert(badges)
+ .values({
+ username: key,
+ visitors: 1,
+ updated_at: now,
+ })
+ .onConflictDoUpdate({
+ target: badges.username,
+ set: {
+ visitors: sql`${badges.visitors} + 1`,
+ updated_at: now,
+ },
+ })
+ .returning({ visitors: badges.visitors });
+
+ return result?.visitors ?? 1;
+ }
+
+ /**
+ * Get project metric
+ */
+ private async getProjectMetric(owner: string, repo: string, type: ProjectBadgeType): Promise {
+ // Implement project metric fetching logic
+ return 0;
+ }
+
+ /**
+ * Build a cache key from category + identifier + type + a normalized
+ * fingerprint of *render-affecting* options. Fields that don't influence
+ * the SVG output (notably `realtime`, which only tunes freshness policy)
+ * are excluded so equivalent requests share a cache entry.
+ *
+ * Bounded input surface here is doubly important because the values
+ * flow into an LRU cap of ~10k entries โ attackers cycling non-render
+ * options would otherwise churn evictions.
+ */
+ private getCacheKey(
+ category: 'user' | 'project',
+ identifier: string,
+ type: string,
+ options: BadgeOptions
+ ): string {
+ const fingerprint = JSON.stringify([
+ options.theme ?? '',
+ options.customLabel ?? '',
+ options.customType ?? '',
+ options.labelColor ?? '',
+ options.labelBackground ?? '',
+ options.iconColor ?? '',
+ options.valueColor ?? '',
+ options.valueBackground ?? '',
+ options.hideFrame === true ? 1 : 0,
+ options.padding ?? 0,
+ ]);
+ return `badge-${category}-${identifier}-${type}-${fingerprint}`;
+ }
+
+ /**
+ * Get cache control header
+ */
+ getCacheControl(): string {
+ return this.HTTP_CACHE_CONTROL;
+ }
+
+ /**
+ * Clear cache
+ */
+ clearCache(): void {
+ this.cache.clear();
+ this.backgroundRefreshAt.clear();
+ logger.info('Badges cache cleared');
+ }
+
+ private canBackgroundRefresh(cacheKey: string, now: number): boolean {
+ const lastRefreshAt = this.backgroundRefreshAt.get(cacheKey) || 0;
+ return now - lastRefreshAt >= this.minBackgroundRefreshIntervalMs;
+ }
+
+ private async fetchAndCacheBadge(cacheKey: string, producer: () => Promise): Promise {
+ const pending = this.pendingRequests.get(cacheKey);
+ if (pending) {
+ return await pending;
+ }
+
+ const promise = producer();
+ this.pendingRequests.set(cacheKey, promise);
+
+ try {
+ const badge = await promise;
+ this.cache.set(cacheKey, { data: badge, timestamp: Date.now() });
+ return badge;
+ } finally {
+ this.pendingRequests.delete(cacheKey);
+ }
+ }
+
+ private refreshBadgeInBackground(cacheKey: string, producer: () => Promise): void {
+ if (this.pendingRequests.has(cacheKey)) {
+ return;
+ }
+
+ this.backgroundRefreshAt.set(cacheKey, Date.now());
+
+ const promise = producer();
+ this.pendingRequests.set(cacheKey, promise);
+
+ promise
+ .then((badge) => {
+ this.cache.set(cacheKey, { data: badge, timestamp: Date.now() });
+ })
+ .catch((error) => {
+ logger.warn('Background badge refresh failed', {
+ cacheKey,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ })
+ .finally(() => {
+ this.pendingRequests.delete(cacheKey);
+ });
+ }
+}
diff --git a/src/modules/badges/badges.types.ts b/src/modules/badges/badges.types.ts
new file mode 100644
index 0000000..91e8a08
--- /dev/null
+++ b/src/modules/badges/badges.types.ts
@@ -0,0 +1,79 @@
+/**
+ * Badges Module Types
+ * Type definitions for badge generation
+ */
+
+export type UserBadgeType =
+ | 'visitors'
+ | 'repositories'
+ | 'organization'
+ | 'languages'
+ | 'followers'
+ | 'total-stars'
+ | 'total-contributors'
+ | 'total-commits'
+ | 'total-code-reviews'
+ | 'total-issues'
+ | 'total-pull-requests'
+ | 'total-joined-years';
+
+export type ProjectBadgeType =
+ | 'stars'
+ | 'forks'
+ | 'contributors'
+ | 'commits'
+ | 'code-reviews'
+ | 'issues'
+ | 'pull-requests'
+ | 'watchers'
+ | 'language'
+ | 'license'
+ | 'size';
+
+export type BadgeEffect = 'wave' | 'glow';
+
+export type BadgeSize = 'small' | 'medium' | 'large';
+
+export type BadgeName = UserBadgeType | ProjectBadgeType;
+
+export interface BadgeOptions {
+ theme?: string;
+ customLabel?: string;
+ labelColor?: string;
+ labelBackground?: string;
+ iconColor?: string;
+ valueColor?: string;
+ valueBackground?: string;
+ hideFrame?: boolean;
+ customType?: string;
+ realtime?: boolean;
+ padding?: number;
+}
+
+export interface BadgeQueryParams extends BadgeOptions {
+ username?: string;
+ repo?: string;
+ name?: string;
+ effect?: BadgeEffect;
+ column?: string;
+ size?: BadgeSize;
+ p?: string;
+}
+
+export interface BadgeCache {
+ data: string;
+ timestamp: number;
+}
+
+export interface BadgeRouteDoc {
+ requiredParams: string[];
+ optionalParams: readonly string[];
+ payload: null;
+ example: string;
+}
+
+export interface BadgeCollectionItem {
+ type: UserBadgeType | ProjectBadgeType;
+ label?: string;
+ options?: BadgeOptions;
+}
diff --git a/src/modules/badges/index.ts b/src/modules/badges/index.ts
new file mode 100644
index 0000000..4d41044
--- /dev/null
+++ b/src/modules/badges/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Badges Module
+ * Exports all badge-related functionality
+ */
+
+export { BadgesController } from './badges.controller.js';
+export { BadgesService } from './badges.service.js';
+export { createBadgesRouter } from './badges.routes.js';
+export type {
+ UserBadgeType,
+ ProjectBadgeType,
+ BadgeOptions,
+ BadgeQueryParams,
+ BadgeCache,
+ BadgeRouteDoc,
+ BadgeCollectionItem
+} from './badges.types.js';
diff --git a/src/modules/graphs/graphs.controller.ts b/src/modules/graphs/graphs.controller.ts
new file mode 100644
index 0000000..502120f
--- /dev/null
+++ b/src/modules/graphs/graphs.controller.ts
@@ -0,0 +1,156 @@
+/**
+ * Graphs Controller
+ * Handles HTTP requests for contribution graphs
+ */
+
+import { Request, Response } from 'express';
+import { GraphsService } from './graphs.service.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { GraphQuery } from '../../shared/validations/validation.js';
+import type { GraphQueryParams } from './graphs.types.js';
+
+const logger = createLogger({ controller: 'GraphsController' });
+
+export class GraphsController {
+ private graphsService: GraphsService;
+
+ static routeDocs = {
+ requiredParams: ['username'],
+ optionalParams: [
+ 'theme',
+ 'year',
+ 'animate',
+ 'size',
+ 'as',
+ 'format',
+ 'show_title',
+ 'show_total_contribution',
+ 'show_background',
+ 'bgColor',
+ 'borderColor',
+ 'textColor',
+ 'titleColor'
+ ],
+ payload: null,
+ example: '/graph?username=pphatdev&animate=wave'
+ };
+
+ constructor(graphsService: GraphsService) {
+ this.graphsService = graphsService;
+ }
+
+ /**
+ * Get contribution graph as SVG
+ */
+ async getSvg(req: Request, res: Response): Promise {
+ const startTime = Date.now();
+
+ try {
+ const params = this.readValidated(req);
+
+ // Generate graph
+ const svg = await this.graphsService.generateGraph(params);
+
+ const duration = Date.now() - startTime;
+ logger.info('Graph SVG generated', {
+ username: params.username,
+ duration
+ });
+
+ res.setHeader('Content-Type', 'image/svg+xml');
+ res.setHeader('Cache-Control', 'public, max-age=3600');
+ res.send(svg);
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ logger.error('Failed to generate graph SVG', error as Error, { duration });
+ res.status(500).send('Failed to generate graph');
+ }
+ }
+
+ /**
+ * Get contribution graph as PNG
+ */
+ async getPng(req: Request, res: Response): Promise {
+ const startTime = Date.now();
+
+ try {
+ const params = this.readValidated(req);
+
+ // Generate SVG first
+ const svg = await this.graphsService.generateGraph(params);
+
+ // Convert to PNG
+ const png = await this.graphsService.convertToPng(svg);
+
+ const duration = Date.now() - startTime;
+ logger.info('Graph PNG generated', {
+ username: params.username,
+ duration
+ });
+
+ res.setHeader('Content-Type', 'image/png');
+ res.setHeader('Cache-Control', 'public, max-age=3600');
+ res.send(png);
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ logger.error('Failed to generate graph PNG', error as Error, { duration });
+ res.status(500).send('Failed to generate graph');
+ }
+ }
+
+ /**
+ * Get contribution graph as WebP
+ */
+ async getWebp(req: Request, res: Response): Promise {
+ const startTime = Date.now();
+
+ try {
+ const params = this.readValidated(req);
+
+ // Generate SVG first
+ const svg = await this.graphsService.generateGraph(params);
+
+ // Convert to WebP
+ const webp = await this.graphsService.convertToWebp(svg);
+
+ const duration = Date.now() - startTime;
+ logger.info('Graph WebP generated', {
+ username: params.username,
+ duration
+ });
+
+ res.setHeader('Content-Type', 'image/webp');
+ res.setHeader('Cache-Control', 'public, max-age=3600');
+ res.send(webp);
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ logger.error('Failed to generate graph WebP', error as Error, { duration });
+ res.status(500).send('Failed to generate graph');
+ }
+ }
+
+ /**
+ * Read the Zod-validated query. `validate(graphQuerySchema, 'query')`
+ * attaches `req.validated`; colors are already normalized to `#โฆ` form
+ * and enums (animate/size/as/format) are already narrowed.
+ */
+ private readValidated(req: Request): GraphQueryParams {
+ const v = (req as Request & { validated?: GraphQuery }).validated ?? ({} as GraphQuery);
+ return {
+ username: v.username as string,
+ theme: v.theme ?? 'default',
+ year: v.year,
+ animate: v.animate,
+ size: v.size,
+ as: v.as,
+ format: v.format,
+ show_title: v.show_title ?? 'false',
+ show_total_contribution: v.show_total_contribution ?? 'false',
+ show_background: v.show_background ?? 'false',
+ bgColor: v.bgColor,
+ borderColor: v.borderColor,
+ textColor: v.textColor,
+ titleColor: v.titleColor,
+ };
+ }
+}
diff --git a/src/modules/graphs/graphs.routes.ts b/src/modules/graphs/graphs.routes.ts
new file mode 100644
index 0000000..db81a75
--- /dev/null
+++ b/src/modules/graphs/graphs.routes.ts
@@ -0,0 +1,60 @@
+/**
+ * Graphs Routes
+ * Defines HTTP routes for graph endpoints
+ */
+
+import { Router } from 'express';
+import { GraphsController } from './graphs.controller.js';
+import { GraphsService } from './graphs.service.js';
+import { GitHubClient } from '../../shared/utils/github-client.js';
+import { validate } from '../../shared/middlewares/error.middleware.js';
+import { graphQuerySchema } from '../../shared/validations/validation.js';
+import type { ResponseCache } from '../../shared/utils/response-cache.js';
+
+export function createGraphsRouter(
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number
+): Router {
+ const router = Router();
+
+ // Initialize service and controller
+ const graphsService = new GraphsService(githubClient, cache, cacheDuration);
+ const graphsController = new GraphsController(graphsService);
+
+ /**
+ * @route GET /graph
+ * @desc Get GitHub contribution graph
+ * @query username - GitHub username (required)
+ * @query format - Output format: svg, png, webp (default: svg)
+ * @query theme - Color theme (default: default)
+ * @query year - Specific year for contributions
+ * @query animate - Animation type
+ * @query size - Graph size
+ * @query show_title - Show title (default: false)
+ * @query show_total_contribution - Show total contributions (default: false)
+ * @query show_background - Show background (default: false)
+ * @query bgColor - Background color
+ * @query borderColor - Border color
+ * @query textColor - Text color
+ * @query titleColor - Title color
+ */
+ router.get('/', validate(graphQuerySchema, 'query'), async (req, res) => {
+ const format = (req.query.format as string) || (req.query.as as string) || 'svg';
+
+ switch (format) {
+ case 'png':
+ await graphsController.getPng(req, res);
+ break;
+ case 'webp':
+ await graphsController.getWebp(req, res);
+ break;
+ case 'svg':
+ default:
+ await graphsController.getSvg(req, res);
+ break;
+ }
+ });
+
+ return router;
+}
diff --git a/src/modules/graphs/graphs.service.ts b/src/modules/graphs/graphs.service.ts
new file mode 100644
index 0000000..de907ef
--- /dev/null
+++ b/src/modules/graphs/graphs.service.ts
@@ -0,0 +1,212 @@
+/**
+ * Graphs Service
+ * Business logic for GitHub contribution graphs
+ */
+
+import { GitHubClient } from '../../shared/utils/github-client.js';
+import { GraphRenderer } from '../../shared/components/graph-renderer.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { GraphQueryParams, GraphCache, GraphOptions, GraphDateRange } from './graphs.types.js';
+import type { ResponseCache } from '../../shared/utils/response-cache.js';
+
+const logger = createLogger({ service: 'GraphsService' });
+let sharpLoader: Promise | null = null;
+let resvgLoader: Promise | null = null;
+
+async function getSharp() {
+ if (!sharpLoader) {
+ sharpLoader = import('sharp').then((module) => module.default);
+ }
+
+ return sharpLoader;
+}
+
+async function getResvg() {
+ if (!resvgLoader) {
+ resvgLoader = import('@resvg/resvg-js').then((module) => module.Resvg);
+ }
+
+ return resvgLoader;
+}
+
+export class GraphsService {
+ private githubClient: GitHubClient;
+ private cache: ResponseCache;
+ private readonly cacheDuration: number;
+
+ constructor(
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number
+ ) {
+ this.githubClient = githubClient;
+ this.cache = cache;
+ this.cacheDuration = cacheDuration;
+ }
+
+ /**
+ * Generate contribution graph
+ */
+ async generateGraph(params: GraphQueryParams): Promise {
+ const dateRange = this.getDateRange(params);
+ const cacheKey = this.getCacheKey(params, dateRange);
+
+ // Check cache
+ const cached = this.cache.get(cacheKey);
+ if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
+ logger.debug('Returning cached graph', { username: params.username });
+ return cached.data;
+ }
+
+ // Fetch contribution data
+ const contributions = await this.githubClient.fetchUserContributions(
+ params.username,
+ dateRange.from,
+ dateRange.to,
+ `${dateRange.displayYear || 'current'}`
+ );
+
+ // When no explicit year was requested, replace the year-scoped total with
+ // an all-time count summed from the user's account creation date. The
+ // heatmap (weeks) still shows the last year โ GitHub's calendar API is
+ // limited to a single 52-week window.
+ // Also merge in `year` (from displayYear) so the renderer's title text
+ // ("'s Activity ") doesn't show "undefined" โ the client's
+ // fetchUserContributions doesn't populate it.
+ // Shallow-copy so we don't mutate the client's cached response.
+ const isDefaultRange = !params.year || params.year === 'last';
+ const renderData = {
+ ...contributions,
+ year: dateRange.displayYear,
+ ...(isDefaultRange && {
+ totalContributions: await this.githubClient.fetchTotalContributionsSinceCreated(params.username),
+ }),
+ };
+
+ // Generate graph
+ const options = this.parseOptions(params);
+ const graphCardOptions = {
+ ...options,
+ year: dateRange.displayYear,
+ show_title: options.showTitle,
+ show_total_contribution: options.showTotalContribution,
+ show_background: options.showBackground,
+ animate: (params.animate as 'none' | 'glow' | 'wave' | 'pulse' | undefined),
+ as: (params.format as 'svg' | 'webp' | 'png' | 'gif' | undefined),
+ size: (params.size as 'small' | 'medium' | 'large' | 'default' | undefined)
+ };
+ const svg = GraphRenderer.generateGraphCard(
+ renderData,
+ graphCardOptions
+ );
+
+ // Cache result
+ this.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
+
+ logger.info('Graph generated', {
+ username: params.username,
+ year: dateRange.displayYear
+ });
+
+ return svg;
+ }
+
+ /**
+ * Convert SVG to PNG
+ */
+ async convertToPng(svg: string): Promise {
+ const Resvg = await getResvg();
+ const resvg = new Resvg(svg);
+
+ const pngData = resvg.render();
+ return pngData.asPng();
+ }
+
+ /**
+ * Convert SVG to WebP
+ */
+ async convertToWebp(svg: string): Promise {
+ const pngBuffer = await this.convertToPng(svg);
+ const sharp = await getSharp();
+ const webpBuffer = await sharp(pngBuffer)
+ .webp({ quality: 90 })
+ .toBuffer();
+ return webpBuffer;
+ }
+
+ /**
+ * Get date range for graph
+ */
+ private getDateRange(params: GraphQueryParams): GraphDateRange {
+ if (params.year && params.year !== 'last') {
+ const y = parseInt(params.year, 10);
+ if (!isNaN(y)) {
+ return {
+ from: `${y}-01-01T00:00:00Z`,
+ to: `${y}-12-31T23:59:59Z`,
+ cacheKeyExtra: y.toString(),
+ displayYear: y
+ };
+ }
+ }
+
+ const now = new Date();
+ const oneYearAgo = new Date();
+ oneYearAgo.setFullYear(now.getFullYear() - 1);
+
+ return {
+ from: oneYearAgo.toISOString(),
+ to: now.toISOString(),
+ cacheKeyExtra: 'last-year',
+ displayYear: 'All Time'
+ };
+ }
+
+ /**
+ * Parse graph options
+ */
+ private parseOptions(params: GraphQueryParams): GraphOptions {
+ return {
+ theme: params.theme || 'default',
+ showTitle: params.show_title === 'true',
+ showTotalContribution: params.show_total_contribution === 'true',
+ showBackground: params.show_background === 'true',
+ bgColor: params.bgColor,
+ borderColor: params.borderColor,
+ textColor: params.textColor,
+ titleColor: params.titleColor
+ };
+ }
+
+ /**
+ * Get cache key
+ * Includes all rendering-affecting params so different options produce distinct keys.
+ * Uses '|' as delimiter to avoid collisions with color values that may contain '-'.
+ */
+ private getCacheKey(params: GraphQueryParams, dateRange: GraphDateRange): string {
+ // Treat 'format' and 'as' as aliases; prefer 'as' when both are provided
+ const outputFormat = params.as || params.format || '';
+ const renderingParams = [
+ params.theme || 'default',
+ params.animate || '',
+ params.size || '',
+ outputFormat,
+ params.show_title || '',
+ params.show_total_contribution || '',
+ params.show_background || '',
+ params.bgColor || '',
+ params.borderColor || '',
+ params.textColor || '',
+ params.titleColor || '',
+ ].join('|');
+ return `graph|${params.username}|${dateRange.cacheKeyExtra}|${renderingParams}`;
+ }
+
+ /**
+ * Clear cache
+ */
+ clearCache(): void {
+ this.cache.clear();
+ logger.info('Graphs cache cleared');
+ }
+}
diff --git a/src/modules/graphs/graphs.types.ts b/src/modules/graphs/graphs.types.ts
new file mode 100644
index 0000000..3c9fdf6
--- /dev/null
+++ b/src/modules/graphs/graphs.types.ts
@@ -0,0 +1,44 @@
+/**
+ * Graphs Module Types
+ * Type definitions for the graphs feature
+ */
+
+export interface GraphQueryParams {
+ username: string;
+ theme?: string;
+ year?: string;
+ animate?: string;
+ size?: string;
+ as?: string;
+ format?: string;
+ show_title?: string;
+ show_total_contribution?: string;
+ show_background?: string;
+ bgColor?: string;
+ borderColor?: string;
+ textColor?: string;
+ titleColor?: string;
+}
+
+export interface GraphCache {
+ data: string;
+ timestamp: number;
+}
+
+export interface GraphOptions {
+ theme: string;
+ showTitle: boolean;
+ showTotalContribution: boolean;
+ showBackground: boolean;
+ bgColor?: string;
+ borderColor?: string;
+ textColor?: string;
+ titleColor?: string;
+}
+
+export interface GraphDateRange {
+ from: string;
+ to: string;
+ displayYear: string | number;
+ cacheKeyExtra: string;
+}
diff --git a/src/modules/graphs/index.ts b/src/modules/graphs/index.ts
new file mode 100644
index 0000000..d42032d
--- /dev/null
+++ b/src/modules/graphs/index.ts
@@ -0,0 +1,14 @@
+/**
+ * Graphs Module
+ * Exports all graph-related functionality
+ */
+
+export { GraphsController } from './graphs.controller.js';
+export { GraphsService } from './graphs.service.js';
+export { createGraphsRouter } from './graphs.routes.js';
+export type {
+ GraphQueryParams,
+ GraphCache,
+ GraphOptions,
+ GraphDateRange
+} from './graphs.types.js';
diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts
new file mode 100644
index 0000000..902a1b9
--- /dev/null
+++ b/src/modules/health/health.controller.ts
@@ -0,0 +1,78 @@
+/**
+ * Health Controller
+ * Handles HTTP requests for health and monitoring endpoints
+ */
+
+import { Request, Response } from 'express';
+import { HealthService } from './health.service.js';
+import { createLogger } from '../../shared/logs/logger.js';
+
+const logger = createLogger({ controller: 'HealthController' });
+
+export class HealthController {
+ private healthService: HealthService;
+
+ constructor(healthService: HealthService) {
+ this.healthService = healthService;
+ }
+
+ /**
+ * Get comprehensive health status
+ */
+ async getHealth(req: Request, res: Response): Promise {
+ try {
+ const health = await this.healthService.performHealthCheck();
+
+ const statusCode = health.status === 'healthy' ? 200 :
+ health.status === 'degraded' ? 200 :
+ 503;
+
+ res.status(statusCode).json(health);
+ } catch (error) {
+ logger.error('Health check failed', error as Error);
+ res.status(503).json({
+ status: 'unhealthy',
+ timestamp: new Date().toISOString(),
+ error: 'Health check failed'
+ });
+ }
+ }
+
+ /**
+ * Get readiness status (for Kubernetes)
+ */
+ async getReady(req: Request, res: Response): Promise {
+ try {
+ const ready = await this.healthService.isReady();
+
+ if (ready) {
+ res.status(200).json({ status: 'ready' });
+ } else {
+ res.status(503).json({ status: 'not ready' });
+ }
+ } catch (error) {
+ logger.error('Readiness check failed', error as Error);
+ res.status(503).json({ status: 'not ready', error: 'Check failed' });
+ }
+ }
+
+ /**
+ * Get liveness status (for Kubernetes)
+ */
+ getLive(req: Request, res: Response): void {
+ const alive = this.healthService.isAlive();
+
+ if (alive) {
+ res.status(200).json({ status: 'alive' });
+ } else {
+ res.status(503).json({ status: 'dead' });
+ }
+ }
+
+ /**
+ * Simple ping endpoint
+ */
+ ping(req: Request, res: Response): void {
+ res.status(200).send('pong');
+ }
+}
diff --git a/src/modules/health/health.routes.ts b/src/modules/health/health.routes.ts
new file mode 100644
index 0000000..fc37a44
--- /dev/null
+++ b/src/modules/health/health.routes.ts
@@ -0,0 +1,50 @@
+/**
+ * Health Routes
+ * Defines HTTP routes for health check endpoints
+ */
+
+import { Router } from 'express';
+import { HealthController } from './health.controller.js';
+import { HealthService } from './health.service.js';
+
+export function createHealthRouter(cacheService?: any): Router {
+ const router = Router();
+
+ // Initialize service and controller
+ const healthService = new HealthService(cacheService);
+ const healthController = new HealthController(healthService);
+
+ /**
+ * @route GET /health
+ * @desc Get comprehensive health status
+ */
+ router.get('/', async (req, res) => {
+ await healthController.getHealth(req, res);
+ });
+
+ /**
+ * @route GET /health/ready
+ * @desc Kubernetes readiness probe
+ */
+ router.get('/ready', async (req, res) => {
+ await healthController.getReady(req, res);
+ });
+
+ /**
+ * @route GET /health/live
+ * @desc Kubernetes liveness probe
+ */
+ router.get('/live', (req, res) => {
+ healthController.getLive(req, res);
+ });
+
+ /**
+ * @route GET /health/ping
+ * @desc Simple ping endpoint
+ */
+ router.get('/ping', (req, res) => {
+ healthController.ping(req, res);
+ });
+
+ return router;
+}
diff --git a/src/modules/health/health.service.ts b/src/modules/health/health.service.ts
new file mode 100644
index 0000000..0393a8b
--- /dev/null
+++ b/src/modules/health/health.service.ts
@@ -0,0 +1,214 @@
+/**
+ * Health Service
+ * Business logic for health checks and system monitoring
+ */
+
+import { db } from '../../db/index.js';
+import { sql } from 'drizzle-orm';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { ICacheService } from '../../services/base.service.js';
+import type { HealthStatus, CheckResult, MemoryUsage } from './health.types.js';
+
+const logger = createLogger({ service: 'HealthService' });
+
+export class HealthService {
+ private startTime: number;
+ private cacheService?: Pick;
+
+ constructor(cacheService?: unknown) {
+ this.startTime = Date.now();
+ this.cacheService = this.isCacheServiceCompatible(cacheService) ? cacheService : undefined;
+
+ if (cacheService && !this.cacheService) {
+ logger.warn('HealthService received incompatible cache service implementation');
+ }
+ }
+
+ private isCacheServiceCompatible(cacheService?: unknown): cacheService is Pick {
+ return Boolean(
+ cacheService
+ && typeof (cacheService as Pick).get === 'function'
+ && typeof (cacheService as Pick).set === 'function'
+ && typeof (cacheService as Pick).del === 'function'
+ );
+ }
+
+ /**
+ * Perform comprehensive health check
+ */
+ async performHealthCheck(): Promise {
+ const [databaseCheck, cacheCheck, memoryCheck] = await Promise.all([
+ this.checkDatabase(),
+ this.checkCache(),
+ this.checkMemory()
+ ]);
+
+ const status = this.determineOverallStatus([
+ databaseCheck,
+ cacheCheck,
+ memoryCheck
+ ]);
+
+ return {
+ status,
+ timestamp: new Date().toISOString(),
+ uptime: this.getUptime(),
+ version: process.env.npm_package_version || '1.0.0',
+ environment: process.env.NODE_ENV || 'development',
+ checks: {
+ database: databaseCheck,
+ cache: cacheCheck,
+ memory: memoryCheck
+ }
+ };
+ }
+
+ /**
+ * Check database connectivity
+ */
+ private async checkDatabase(): Promise {
+ const startTime = Date.now();
+
+ try {
+ // Simple query to test database
+ await db.get(sql`SELECT 1`);
+
+ return {
+ status: 'pass',
+ responseTime: Date.now() - startTime,
+ };
+ } catch (error) {
+ logger.error('Database health check failed', error as Error);
+ return {
+ status: 'fail',
+ message: 'Database connection failed',
+ responseTime: Date.now() - startTime,
+ };
+ }
+ }
+
+ /**
+ * Check cache service
+ */
+ private async checkCache(): Promise {
+ const startTime = Date.now();
+
+ if (!this.cacheService) {
+ return {
+ status: 'warn',
+ message: 'Cache service not initialized',
+ };
+ }
+
+ try {
+ // Test cache connectivity with a simple operation
+ const testKey = 'health-check-test';
+ const testValue = Date.now().toString();
+
+ await this.cacheService.set(testKey, testValue, 10);
+ const retrieved = await this.cacheService.get(testKey);
+ const normalizedRetrieved = retrieved == null ? null : String(retrieved);
+
+ if (normalizedRetrieved !== testValue) {
+ throw new Error('Cache read/write mismatch');
+ }
+
+ await this.cacheService.del(testKey);
+
+ return {
+ status: 'pass',
+ responseTime: Date.now() - startTime,
+ };
+ } catch (error) {
+ logger.error('Cache health check failed', error as Error);
+ return {
+ status: 'fail',
+ message: 'Cache service failed',
+ responseTime: Date.now() - startTime,
+ };
+ }
+ }
+
+ /**
+ * Check memory usage
+ */
+ private async checkMemory(): Promise {
+ const memoryUsage = this.getMemoryUsage();
+ const threshold = 90; // 90% threshold
+
+ if (memoryUsage.percentage >= threshold) {
+ logger.warn('High memory usage detected', { memoryUsage });
+ return {
+ status: 'warn',
+ message: `Memory usage at ${memoryUsage.percentage.toFixed(2)}%`,
+ details: memoryUsage
+ };
+ }
+
+ return {
+ status: 'pass',
+ details: memoryUsage
+ };
+ }
+
+ /**
+ * Get memory usage information
+ */
+ private getMemoryUsage(): MemoryUsage {
+ const mem = process.memoryUsage();
+ const used = mem.heapUsed;
+ const total = mem.heapTotal;
+ const percentage = (used / total) * 100;
+
+ return {
+ used: Math.round(used / 1024 / 1024), // MB
+ total: Math.round(total / 1024 / 1024), // MB
+ percentage: Math.round(percentage * 100) / 100
+ };
+ }
+
+ /**
+ * Get application uptime in seconds
+ */
+ private getUptime(): number {
+ return Math.floor((Date.now() - this.startTime) / 1000);
+ }
+
+ /**
+ * Determine overall health status based on individual checks
+ */
+ private determineOverallStatus(checks: CheckResult[]): 'healthy' | 'degraded' | 'unhealthy' {
+ const hasFailure = checks.some(check => check.status === 'fail');
+ const hasWarning = checks.some(check => check.status === 'warn');
+
+ if (hasFailure) {
+ return 'unhealthy';
+ }
+
+ if (hasWarning) {
+ return 'degraded';
+ }
+
+ return 'healthy';
+ }
+
+ /**
+ * Get simple readiness check
+ */
+ async isReady(): Promise {
+ try {
+ await db.get(sql`SELECT 1`);
+ return true;
+ } catch (error) {
+ logger.error('Readiness check failed', error as Error);
+ return false;
+ }
+ }
+
+ /**
+ * Get simple liveness check
+ */
+ isAlive(): boolean {
+ return true;
+ }
+}
diff --git a/src/modules/health/health.types.ts b/src/modules/health/health.types.ts
new file mode 100644
index 0000000..677f0bf
--- /dev/null
+++ b/src/modules/health/health.types.ts
@@ -0,0 +1,30 @@
+/**
+ * Health Module Types
+ * Type definitions for health check and monitoring
+ */
+
+export interface HealthStatus {
+ status: 'healthy' | 'degraded' | 'unhealthy';
+ timestamp: string;
+ uptime: number;
+ version: string;
+ environment: string;
+ checks: {
+ database: CheckResult;
+ cache: CheckResult;
+ memory: CheckResult;
+ };
+}
+
+export interface CheckResult {
+ status: 'pass' | 'fail' | 'warn';
+ message?: string;
+ responseTime?: number;
+ details?: any;
+}
+
+export interface MemoryUsage {
+ used: number;
+ total: number;
+ percentage: number;
+}
diff --git a/src/modules/health/index.ts b/src/modules/health/index.ts
new file mode 100644
index 0000000..e21d1cb
--- /dev/null
+++ b/src/modules/health/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Health Module
+ * Exports all health check and monitoring functionality
+ */
+
+export { HealthController } from './health.controller.js';
+export { HealthService } from './health.service.js';
+export { createHealthRouter } from './health.routes.js';
+export type {
+ HealthStatus,
+ CheckResult,
+ MemoryUsage
+} from './health.types.js';
diff --git a/src/controllers/icons-collection.controller.ts b/src/modules/icons/icons-collection.controller.ts
similarity index 59%
rename from src/controllers/icons-collection.controller.ts
rename to src/modules/icons/icons-collection.controller.ts
index 42d61c5..07eaa34 100644
--- a/src/controllers/icons-collection.controller.ts
+++ b/src/modules/icons/icons-collection.controller.ts
@@ -1,417 +1,515 @@
-import type { Request, Response } from 'express';
-import fs from 'fs/promises';
-import path from 'path';
-import { createHash } from 'crypto';
-import { fileURLToPath } from 'url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-export class IconsCollectionController {
- private static readonly iconsDir = path.join(__dirname, '..', '..', 'public', 'assets', 'icons');
- private static readonly svgCache: Map = new Map();
- private static readonly pendingLoads: Map> = new Map();
- private static readonly MAX_CACHE_ITEMS = 2000;
- private static readonly HTTP_CACHE_CONTROL = 'public, max-age=31536000, immutable';
- private static readonly COLOR_REGEX = /^(#[0-9A-Fa-f]{3,8}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|hsla\([^)]+\)|[a-zA-Z]+|currentColor)$/;
- private static readonly ICON_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
- private static readonly DEFAULT_ICON_COLUMNS = 3;
- private static readonly MAX_ICON_COLUMNS = 40;
- private static readonly MULTI_ICON_COLOR_PALETTE = [
- '#38BDF8', '#F97316', '#10B981', '#A855F7', '#F43F5E', '#EAB308', '#14B8A6', '#3B82F6', '#EF4444', '#22C55E'
- ];
- private static readonly ICON_SIZE_PRESETS = {
- small: { icon: 40, cell: 52, gap: 0, padding: 5 },
- medium: { icon: 56, cell: 68, gap: 0, padding: 5 },
- large: { icon: 72, cell: 84, gap: 0, padding: 5 },
- } as const;
-
- private static createWeakEtag(content: string): string {
- const hash = createHash('sha1').update(content).digest('base64url');
- return `W/"${hash}"`;
- }
-
- private static maybePruneCache(): void {
- if (IconsCollectionController.svgCache.size <= IconsCollectionController.MAX_CACHE_ITEMS) {
- return;
- }
-
- const overflowCount = IconsCollectionController.svgCache.size - IconsCollectionController.MAX_CACHE_ITEMS;
- let removed = 0;
- for (const key of IconsCollectionController.svgCache.keys()) {
- IconsCollectionController.svgCache.delete(key);
- removed += 1;
- if (removed >= overflowCount) {
- break;
- }
- }
- }
-
- private static setImageHeaders(res: Response, etag: string): void {
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', IconsCollectionController.HTTP_CACHE_CONTROL);
- res.setHeader('ETag', etag);
- }
-
- private static isValidColor(color: string): boolean {
- return IconsCollectionController.COLOR_REGEX.test(color);
- }
-
- private static parseQueryList(value: unknown): string[] {
- const values = Array.isArray(value) ? value : [value];
-
- return values
- .flatMap((entry) => typeof entry === 'string' ? entry.split(',') : [])
- .map((entry) => entry.trim())
- .filter(Boolean);
- }
-
- private static resolveIconPath(iconName: string): string | null {
- if (!IconsCollectionController.ICON_NAME_REGEX.test(iconName)) {
- return null;
- }
-
- const resolvedIconsDir = path.resolve(IconsCollectionController.iconsDir);
- const iconPath = path.resolve(IconsCollectionController.iconsDir, `${iconName}.svg`);
-
- if (!iconPath.startsWith(resolvedIconsDir + path.sep) && iconPath !== resolvedIconsDir) {
- return null;
- }
-
- return iconPath;
- }
-
- private static async readBaseIconContent(iconName: string): Promise {
- const iconPath = IconsCollectionController.resolveIconPath(iconName);
-
- if (!iconPath) {
- throw new Error('INVALID_ICON_NAME');
- }
-
- let pending = IconsCollectionController.pendingLoads.get(iconName);
- if (!pending) {
- pending = fs.readFile(iconPath, 'utf-8');
- IconsCollectionController.pendingLoads.set(iconName, pending);
- pending.finally(() => IconsCollectionController.pendingLoads.delete(iconName));
- }
-
- return pending;
- }
-
- private static getFallbackColor(iconName: string, index: number): string {
- const hash = createHash('sha1').update(`${iconName}:${index}`).digest('hex');
- const paletteIndex = parseInt(hash.slice(0, 8), 16) % IconsCollectionController.MULTI_ICON_COLOR_PALETTE.length;
- return IconsCollectionController.MULTI_ICON_COLOR_PALETTE[paletteIndex];
- }
-
- private static normalizeSize(value: unknown): keyof typeof IconsCollectionController.ICON_SIZE_PRESETS | null {
- if (typeof value === 'undefined') {
- return 'medium';
- }
-
- if (typeof value !== 'string') {
- return null;
- }
-
- const normalized = value.trim().toLowerCase();
- return normalized in IconsCollectionController.ICON_SIZE_PRESETS
- ? normalized as keyof typeof IconsCollectionController.ICON_SIZE_PRESETS
- : null;
- }
-
- private static normalizeEffect(value: unknown): 'glow' | 'wave' | undefined | null {
- if (typeof value === 'undefined') {
- return undefined;
- }
-
- if (typeof value !== 'string') {
- return null;
- }
-
- const normalized = value.trim().toLowerCase();
- if (normalized === 'glow' || normalized === 'wave') {
- return normalized;
- }
-
- return null;
- }
-
- private static normalizeColumns(value: unknown): number | null {
- if (typeof value === 'undefined') {
- return IconsCollectionController.DEFAULT_ICON_COLUMNS;
- }
-
- if (typeof value !== 'string') {
- return null;
- }
-
- const parsed = Number.parseInt(value, 10);
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > IconsCollectionController.MAX_ICON_COLUMNS) {
- return null;
- }
-
- return parsed;
- }
-
- private static applySvgColor(svgContent: string, color: string): string {
- let result = svgContent;
-
- // First, map the standard currentColor usage.
- const beforeCurrentColor = result;
- result = result.replace(/fill="currentColor"/gi, `fill="${color}"`);
- result = result.replace(/fill='currentColor'/gi, `fill='${color}'`);
- result = result.replace(/stroke="currentColor"/gi, `stroke="${color}"`);
- result = result.replace(/stroke='currentColor'/gi, `stroke='${color}'`);
-
- // Some icon assets are authored with hardcoded black values. If no
- // currentColor replacement happened, remap common black tokens so icons
- // remain visible on dark backgrounds in collection mode.
- if (result === beforeCurrentColor) {
- result = result.replace(/fill="(?:#000(?:000)?|black|rgb\(0\s*,\s*0\s*,\s*0\))"/gi, `fill="${color}"`);
- result = result.replace(/fill='(?:#000(?:000)?|black|rgb\(0\s*,\s*0\s*,\s*0\))'/gi, `fill='${color}'`);
- result = result.replace(/stroke="(?:#000(?:000)?|black|rgb\(0\s*,\s*0\s*,\s*0\))"/gi, `stroke="${color}"`);
- result = result.replace(/stroke='(?:#000(?:000)?|black|rgb\(0\s*,\s*0\s*,\s*0\))'/gi, `stroke='${color}'`);
- }
-
- return result;
- }
-
- private static generateCollectionCacheKey(
- iconNames: string[],
- colors: string[],
- size: keyof typeof IconsCollectionController.ICON_SIZE_PRESETS,
- effect: 'glow' | 'wave' | undefined,
- columns: number
- ): string {
- return [
- 'collection',
- `icons:${iconNames.join(',')}`,
- `colors:${colors.join(',')}`,
- `size:${size}`,
- `effect:${effect ?? 'none'}`,
- `columns:${columns}`,
- ].join('|');
- }
-
- private static buildInlineCollectionIconSvg(
- svgContent: string,
- x: number,
- y: number,
- size: number,
- className: string,
- styleAttribute: string,
- filterId?: string
- ): string {
- const cleanedSvg = svgContent
- .replace(/^\s*<\?xml[^>]*>\s*/i, '')
- .trim();
-
- return cleanedSvg.replace(/]*)>/i, (_match, attributes: string) => {
- const cleanedAttributes = attributes
- .replace(/\s(?:x|y|width|height|class|style|filter|preserveAspectRatio|overflow)=("[^"]*"|'[^']*')/gi, '')
- .trim();
- const classPart = className ? ` class="${className}"` : '';
- const stylePart = styleAttribute ? ` style="${styleAttribute}"` : '';
- const filterPart = filterId ? ` filter="url(#${filterId})"` : '';
-
- return ``;
- });
- }
-
- private static buildGlowFilterDefinition(filterId: string, color: string): string {
- return `
-
-
-
-
-
-
-
- `;
- }
-
- private static async buildIconsCollectionSvg(
- iconNames: string[],
- colors: string[],
- size: keyof typeof IconsCollectionController.ICON_SIZE_PRESETS,
- effect: 'glow' | 'wave' | undefined,
- columns: number
- ): Promise {
- const preset = IconsCollectionController.ICON_SIZE_PRESETS[size];
- const totalColumns = Math.min(columns, iconNames.length);
- const totalRows = Math.ceil(iconNames.length / totalColumns);
- const width = preset.padding * 2 + totalColumns * preset.cell + (totalColumns - 1) * preset.gap;
- const height = preset.padding * 2 + totalRows * preset.cell + (totalRows - 1) * preset.gap;
- const defs: string[] = [];
- const waveStyles = effect === 'wave'
- ? ``
- : '';
-
- const images = await Promise.all(iconNames.map(async (iconName, index) => {
- const resolvedColor = colors[index] ?? IconsCollectionController.getFallbackColor(iconName, index);
- const row = Math.floor(index / totalColumns);
- const column = index % totalColumns;
- const x = preset.padding + column * (preset.cell + preset.gap) + (preset.cell - preset.icon) / 2;
- const y = preset.padding + row * (preset.cell + preset.gap) + (preset.cell - preset.icon) / 2;
- const delay = (column * 0.12) + (row * 0.08);
- const baseContent = await IconsCollectionController.readBaseIconContent(iconName);
- const coloredContent = IconsCollectionController.applySvgColor(baseContent, resolvedColor);
- const effectStyles: string[] = [];
- let glowFilterId: string | undefined;
-
- if (effect === 'glow') {
- glowFilterId = `icon-glow-${index}`;
- defs.push(IconsCollectionController.buildGlowFilterDefinition(glowFilterId, resolvedColor));
- }
-
- if (effect === 'wave') {
- effectStyles.push(`animation-delay: ${delay.toFixed(2)}s;`);
- }
-
- const className = effect === 'wave' ? ' class="icon-wave"' : '';
- const styleAttribute = effectStyles.length > 0 ? ` style="${effectStyles.join(' ')}"` : '';
-
- return IconsCollectionController.buildInlineCollectionIconSvg(
- coloredContent,
- x,
- y,
- preset.icon,
- effect === 'wave' ? 'icon-wave' : '',
- effectStyles.join(' '),
- glowFilterId
- );
- }));
-
- return `
-
- Icon collection
- ${defs.length > 0 ? `
- ${defs.join('\n ')}
- ` : ''}
- ${waveStyles}
- ${images.join('\n ')}
- `;
- }
-
- static async getIconsCollection(req: Request, res: Response): Promise {
- try {
- const iconNames = IconsCollectionController.parseQueryList(req.query.name);
- const colors = IconsCollectionController.parseQueryList(req.query.color);
- const invalidNames = iconNames.filter((iconName) => !IconsCollectionController.ICON_NAME_REGEX.test(iconName));
- const invalidColors = colors.filter((color) => !IconsCollectionController.isValidColor(color));
- const size = IconsCollectionController.normalizeSize(req.query.size);
- const effect = IconsCollectionController.normalizeEffect(req.query.effect);
- const columns = IconsCollectionController.normalizeColumns(req.query.columns);
-
- if (iconNames.length === 0) {
- res.status(400).json({
- error: 'Missing icon names',
- message: 'Provide at least one icon name using the name query parameter, for example /icons?name=react,typescript'
- });
- return;
- }
-
- if (invalidNames.length > 0) {
- res.status(400).json({
- error: 'Invalid icon name',
- message: 'Icon names must contain only alphanumeric characters, dots, underscores, and hyphens',
- invalid_names: invalidNames
- });
- return;
- }
-
- if (invalidColors.length > 0) {
- res.status(400).json({
- error: 'Invalid color parameter',
- message: 'Every color must be a valid hex color (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba, hsl/hsla, named color, or currentColor',
- invalid_colors: invalidColors
- });
- return;
- }
-
- if (!size) {
- res.status(400).json({
- error: 'Invalid size parameter',
- message: 'Size must be one of: small, medium, large'
- });
- return;
- }
-
- if (effect === null) {
- res.status(400).json({
- error: 'Invalid effect parameter',
- message: 'Effect must be one of: glow, wave'
- });
- return;
- }
-
- if (columns === null) {
- res.status(400).json({
- error: 'Invalid columns parameter',
- message: `Columns must be an integer between 1 and ${IconsCollectionController.MAX_ICON_COLUMNS}`
- });
- return;
- }
-
- const missingIcons = (await Promise.all(iconNames.map(async (iconName) => {
- try {
- await fs.access(path.resolve(IconsCollectionController.iconsDir, `${iconName}.svg`));
- return null;
- } catch {
- return iconName;
- }
- }))).filter((iconName): iconName is string => Boolean(iconName));
-
- if (missingIcons.length > 0) {
- res.status(404).json({
- error: 'Icon not found',
- missing_icons: missingIcons,
- available_icons: '/icons'
- });
- return;
- }
-
- const cacheKey = IconsCollectionController.generateCollectionCacheKey(iconNames, colors, size, effect, columns);
- const cached = IconsCollectionController.svgCache.get(cacheKey);
- if (cached) {
- if (req.headers['if-none-match'] === cached.etag) {
- res.status(304).end();
- return;
- }
-
- IconsCollectionController.setImageHeaders(res, cached.etag);
- res.send(cached.content);
- return;
- }
-
- const svgContent = await IconsCollectionController.buildIconsCollectionSvg(iconNames, colors, size, effect, columns);
- const etag = IconsCollectionController.createWeakEtag(svgContent);
-
- if (req.headers['if-none-match'] === etag) {
- res.status(304).end();
- return;
- }
-
- IconsCollectionController.svgCache.set(cacheKey, { content: svgContent, etag, timestamp: Date.now() });
- IconsCollectionController.maybePruneCache();
-
- IconsCollectionController.setImageHeaders(res, etag);
- res.send(svgContent);
- } catch (error) {
- res.status(500).json({
- error: 'Failed to render icon collection',
- message: error instanceof Error ? error.message : 'Unknown error'
- });
- }
- }
-}
+/**
+ * Icons Collection Controller
+ * Handles HTTP requests for icon collection rendering with caching, validation, and effects
+ */
+
+import type { Request, Response } from 'express';
+import fs from 'fs/promises';
+import path from 'path';
+import { createHash } from 'crypto';
+import { fileURLToPath } from 'url';
+import { isValidCssColor } from '../../shared/utils/css-color.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+export class IconsCollectionController {
+ private static readonly iconsDir = path.join(__dirname, '..', '..', '..', 'public', 'assets', 'icons');
+ private static readonly svgCache: Map = new Map();
+ private static readonly pendingLoads: Map> = new Map();
+ private static readonly MAX_CACHE_ITEMS = 2000;
+ private static readonly HTTP_CACHE_CONTROL = 'public, max-age=31536000, immutable';
+ private static readonly ICON_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
+ private static readonly DEFAULT_ICON_COLUMNS = 3;
+ private static readonly MAX_ICON_COLUMNS = 40;
+ private static readonly MULTI_ICON_COLOR_PALETTE = [
+ '#38BDF8',
+ '#F97316',
+ '#10B981',
+ '#A855F7',
+ '#F43F5E',
+ '#EAB308',
+ '#14B8A6',
+ '#3B82F6',
+ '#EF4444',
+ '#22C55E',
+ ];
+ private static readonly ICON_SIZE_PRESETS = {
+ small: { icon: 40, cell: 52, gap: 0, padding: 5 },
+ medium: { icon: 56, cell: 68, gap: 0, padding: 5 },
+ large: { icon: 72, cell: 84, gap: 0, padding: 5 },
+ } as const;
+
+ private static createWeakEtag(content: string): string {
+ const hash = createHash('sha1').update(content).digest('base64url');
+ return `W/"${hash}"`;
+ }
+
+ private static maybePruneCache(): void {
+ if (IconsCollectionController.svgCache.size <= IconsCollectionController.MAX_CACHE_ITEMS) {
+ return;
+ }
+
+ const overflowCount = IconsCollectionController.svgCache.size - IconsCollectionController.MAX_CACHE_ITEMS;
+ let removed = 0;
+
+ for (const key of IconsCollectionController.svgCache.keys()) {
+ IconsCollectionController.svgCache.delete(key);
+ removed += 1;
+ if (removed >= overflowCount) {
+ break;
+ }
+ }
+ }
+
+ private static setImageHeaders(res: Response, etag: string): void {
+ res.setHeader('Content-Type', 'image/svg+xml');
+ res.setHeader('Cache-Control', IconsCollectionController.HTTP_CACHE_CONTROL);
+ res.setHeader('ETag', etag);
+ }
+
+ private static isValidColor(color: string): boolean {
+ return isValidCssColor(color);
+ }
+
+ private static parseQueryList(value: unknown): string[] {
+ const values = Array.isArray(value) ? value : [value];
+ return values
+ .flatMap((entry) => (typeof entry === 'string' ? entry.split(',') : []))
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ }
+
+ private static readonly ICON_ALIASES: Record = {
+ nodedotjs: 'nodejs',
+ node: 'nodejs',
+ vue: 'vuedotjs',
+ ember: 'emberdotjs',
+ emberjs: 'emberdotjs',
+ three: 'threedotjs',
+ threejs: 'threedotjs',
+ chart: 'chartdotjs',
+ chartjs: 'chartdotjs',
+ fly: 'flydotio',
+ flyio: 'flydotio',
+ devto: 'devdotto',
+ gitignoredotio: 'gitignoredotio',
+ gitignore: 'gitignoredotio',
+ js: 'javascript',
+ ts: 'typescript',
+ py: 'python',
+ cpp: 'cplusplus',
+ 'c++': 'cplusplus',
+ cs: 'csharp',
+ postgres: 'postgresql',
+ golang: 'go',
+ };
+
+ public static resolveIconName(iconName: string): string {
+ const normalized = iconName.toLowerCase();
+ return IconsCollectionController.ICON_ALIASES[normalized] || normalized;
+ }
+
+ private static resolveIconPath(iconName: string): string | null {
+ const targetName = IconsCollectionController.resolveIconName(iconName);
+ if (!IconsCollectionController.ICON_NAME_REGEX.test(targetName)) {
+ return null;
+ }
+
+ const resolvedIconsDir = path.resolve(IconsCollectionController.iconsDir);
+ const iconPath = path.resolve(IconsCollectionController.iconsDir, `${targetName}.svg`);
+
+ if (!iconPath.startsWith(resolvedIconsDir + path.sep) && iconPath !== resolvedIconsDir) {
+ return null;
+ }
+
+ return iconPath;
+ }
+
+ private static async readBaseIconContent(iconName: string): Promise {
+ const targetName = IconsCollectionController.resolveIconName(iconName);
+ const iconPath = IconsCollectionController.resolveIconPath(targetName);
+ if (!iconPath) {
+ throw new Error('INVALID_ICON_NAME');
+ }
+
+ let pending = IconsCollectionController.pendingLoads.get(targetName);
+ if (!pending) {
+ pending = fs.readFile(iconPath, 'utf-8').then((content) => {
+ // Strip embedded individual icon popup styles so collection rendering is clean and instant
+ return content.replace(/`
+ : '';
+
+ const images = await Promise.all(
+ iconNames.map(async (iconName, index) => {
+ const resolvedColor = colors[index] ?? IconsCollectionController.getFallbackColor(iconName, index);
+ const row = Math.floor(index / totalColumns);
+ const column = index % totalColumns;
+ const x =
+ preset.padding +
+ column * (preset.cell + preset.gap) +
+ (preset.cell - preset.icon) / 2;
+ const y =
+ preset.padding +
+ waveYOffset +
+ row * (preset.cell + preset.gap) +
+ (preset.cell - preset.icon) / 2;
+ const delay = column * 0.12 + row * 0.08;
+
+ const baseContent = await IconsCollectionController.readBaseIconContent(iconName);
+ const coloredContent = IconsCollectionController.applySvgColor(baseContent, resolvedColor);
+ const effectStyles: string[] = [];
+ let glowFilterId: string | undefined;
+
+ if (effect === 'glow') {
+ glowFilterId = `icon-glow-${index}`;
+ defs.push(IconsCollectionController.buildGlowFilterDefinition(glowFilterId, resolvedColor));
+ }
+
+ if (effect === 'wave') {
+ effectStyles.push(`animation-delay: ${delay.toFixed(2)}s;`);
+ }
+
+ return IconsCollectionController.buildInlineCollectionIconSvg(
+ coloredContent,
+ x,
+ y,
+ preset.icon,
+ effect === 'wave' ? 'icon-wave' : '',
+ effectStyles.join(' '),
+ glowFilterId,
+ );
+ }),
+ );
+
+ return `
+ Icon collection
+ ${defs.length > 0 ? `
+ ${defs.join('\n ')}
+ ` : ''}
+ ${waveStyles}
+ ${images.join('\n ')} `;
+ }
+
+ static async getIconsCollection(req: Request, res: Response): Promise {
+ try {
+ const iconNames = IconsCollectionController.parseQueryList(req.query.name);
+ const colors = IconsCollectionController.parseQueryList(req.query.color);
+ const invalidNames = iconNames.filter(
+ (iconName) => !IconsCollectionController.ICON_NAME_REGEX.test(iconName),
+ );
+ const invalidColors = colors.filter((color) => !IconsCollectionController.isValidColor(color));
+ const size = IconsCollectionController.normalizeSize(req.query.size);
+ const effect = IconsCollectionController.normalizeEffect(req.query.effect);
+ const columns = IconsCollectionController.normalizeColumns(req.query.columns);
+
+ if (iconNames.length === 0) {
+ res.status(400).json({
+ error: 'Missing icon names',
+ message:
+ 'Provide at least one icon name using the name query parameter, for example /icons?name=react,typescript',
+ });
+ return;
+ }
+
+ if (invalidNames.length > 0) {
+ res.status(400).json({
+ error: 'Invalid icon name',
+ message: 'Icon names must contain only alphanumeric characters, dots, underscores, and hyphens',
+ invalid_names: invalidNames,
+ });
+ return;
+ }
+
+ // Normalize icon names to lowercase for case-insensitive file lookup
+ const normalizedIconNames = iconNames.map((name) => name.toLowerCase());
+
+ if (invalidColors.length > 0) {
+ res.status(400).json({
+ error: 'Invalid color parameter',
+ message:
+ 'Every color must be a valid hex color (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba, hsl/hsla, named color, or currentColor',
+ invalid_colors: invalidColors,
+ });
+ return;
+ }
+
+ if (!size) {
+ res.status(400).json({
+ error: 'Invalid size parameter',
+ message: 'Size must be one of: small, medium, large',
+ });
+ return;
+ }
+
+ if (effect === null) {
+ res.status(400).json({
+ error: 'Invalid effect parameter',
+ message: 'Effect must be one of: glow, wave',
+ });
+ return;
+ }
+
+ if (columns === null) {
+ res.status(400).json({
+ error: 'Invalid columns parameter',
+ message: `Columns must be an integer between 1 and ${IconsCollectionController.MAX_ICON_COLUMNS}`,
+ });
+ return;
+ }
+
+ const resolvedIconNames = (
+ await Promise.all(
+ normalizedIconNames.map(async (rawName) => {
+ const targetName = IconsCollectionController.resolveIconName(rawName);
+ try {
+ await fs.access(
+ path.resolve(IconsCollectionController.iconsDir, `${targetName}.svg`),
+ );
+ return targetName;
+ } catch {
+ return null;
+ }
+ }),
+ )
+ ).filter((iconName): iconName is string => Boolean(iconName));
+
+ if (resolvedIconNames.length === 0) {
+ res.status(200).setHeader('Content-Type', 'image/svg+xml').send(
+ ' ',
+ );
+ return;
+ }
+
+ const cacheKey = IconsCollectionController.generateCollectionCacheKey(
+ resolvedIconNames,
+ colors,
+ size,
+ effect,
+ columns,
+ );
+ const cached = IconsCollectionController.svgCache.get(cacheKey);
+
+ if (cached) {
+ if (req.headers['if-none-match'] === cached.etag) {
+ res.status(304).end();
+ return;
+ }
+
+ IconsCollectionController.setImageHeaders(res, cached.etag);
+ res.send(cached.content);
+ return;
+ }
+
+ const svgContent = await IconsCollectionController.buildIconsCollectionSvg(
+ resolvedIconNames,
+ colors,
+ size,
+ effect,
+ columns,
+ );
+ const etag = IconsCollectionController.createWeakEtag(svgContent);
+
+ if (req.headers['if-none-match'] === etag) {
+ res.status(304).end();
+ return;
+ }
+
+ IconsCollectionController.svgCache.set(cacheKey, { content: svgContent, etag, timestamp: Date.now() });
+ IconsCollectionController.maybePruneCache();
+
+ IconsCollectionController.setImageHeaders(res, etag);
+ res.send(svgContent);
+ } catch (error) {
+ res.status(500).json({
+ error: 'Failed to render icon collection',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+}
diff --git a/src/modules/icons/icons.controller.ts b/src/modules/icons/icons.controller.ts
new file mode 100644
index 0000000..8e6ce26
--- /dev/null
+++ b/src/modules/icons/icons.controller.ts
@@ -0,0 +1,105 @@
+/**
+ * Icons Controller
+ * Handles HTTP requests for icon retrieval
+ */
+
+import { Request, Response } from 'express';
+import { IconsService } from './icons.service.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import { generateIconsDemoHTML } from '../../views/icons-demo.view.js';
+import type { IconQueryParams } from './icons.types.js';
+
+const logger = createLogger({ controller: 'IconsController' });
+
+export class IconsController {
+ private iconsService: IconsService;
+
+ constructor(iconsService: IconsService) {
+ this.iconsService = iconsService;
+ }
+
+ /**
+ * Get icon SVG
+ */
+ async getIcon(req: Request, res: Response): Promise {
+ const startTime = Date.now();
+
+ try {
+ const iconName = req.params.icon;
+ const { color } = req.query;
+
+ if (!iconName) {
+ res.status(400).send('Icon name is required');
+ return;
+ }
+
+ const { content, etag } = await this.iconsService.getIcon(
+ iconName,
+ color as string | undefined
+ );
+
+ // Check ETag for conditional requests
+ if (req.headers['if-none-match'] === etag) {
+ res.status(304).end();
+ return;
+ }
+
+ const duration = Date.now() - startTime;
+ logger.info('Icon served', { iconName, color, duration });
+
+ res.setHeader('Content-Type', 'image/svg+xml');
+ res.setHeader('Cache-Control', this.iconsService.getCacheControl());
+ res.setHeader('ETag', etag);
+ res.send(content);
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ logger.error('Failed to serve icon', error as Error, { duration });
+
+ const err = error as NodeJS.ErrnoException;
+ if (err.message?.includes('Invalid')) {
+ res.status(400).send('Invalid icon name');
+ return;
+ }
+ // Only ENOENT genuinely means "no such file" โ anything else
+ // (EACCES, EMFILE, โฆ) is a server-side problem and shouldn't
+ // masquerade as a 404 (L5).
+ if (err.code === 'ENOENT') {
+ res.status(404).send('Icon not found');
+ return;
+ }
+ res.status(500).send('Failed to serve icon');
+ }
+ }
+
+ /**
+ * Get icons list
+ */
+ async getIconsList(req: Request, res: Response): Promise {
+ try {
+ const icons = await this.iconsService.loadIconsList();
+
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Cache-Control', 'public, max-age=3600');
+ res.json({ icons, count: icons.length });
+ } catch (error) {
+ logger.error('Failed to load icons list', error as Error);
+ res.status(500).send('Failed to load icons list');
+ }
+ }
+
+ /**
+ * Show icons demo page
+ */
+ async showDemo(req: Request, res: Response): Promise {
+ try {
+ const icons = await this.iconsService.loadIconsList();
+ const html = generateIconsDemoHTML({ icons });
+
+ res.setHeader('Content-Type', 'text/html');
+ res.send(html);
+ } catch (error) {
+ logger.error('Failed to generate icons demo', error as Error);
+ res.status(500).send('Failed to generate icons demo');
+ }
+ }
+}
diff --git a/src/modules/icons/icons.routes.ts b/src/modules/icons/icons.routes.ts
new file mode 100644
index 0000000..8adb58c
--- /dev/null
+++ b/src/modules/icons/icons.routes.ts
@@ -0,0 +1,54 @@
+/**
+ * Icons Routes
+ * Defines HTTP routes for icon endpoints
+ */
+
+import { Router } from 'express';
+import { IconsController } from './icons.controller.js';
+import { IconsCollectionController } from './icons-collection.controller.js';
+import { IconsService } from './icons.service.js';
+
+export function createIconsRouter(): Router {
+ const router = Router();
+
+ // Initialize service and controller
+ const iconsService = new IconsService();
+ const iconsController = new IconsController(iconsService);
+
+ /**
+ * @route GET /icons
+ * @desc Get list of available icons or icon collection if name parameter provided
+ * @query name - Comma-separated icon names for collection (optional)
+ * @query size - Icon size: small, medium, large (optional, defaults to medium)
+ * @query color - Comma-separated colors for each icon (optional)
+ * @query effect - Animation effect: glow, wave (optional)
+ * @query columns - Number of columns in grid (optional, defaults to 3, max 40)
+ */
+ router.get('/', async (req, res) => {
+ if (req.query.name) {
+ await IconsCollectionController.getIconsCollection(req, res);
+ } else {
+ await iconsController.getIconsList(req, res);
+ }
+ });
+
+ /**
+ * @route GET /icons/demo
+ * @desc Show icons demo page
+ */
+ router.get('/demo', async (req, res) => {
+ await iconsController.showDemo(req, res);
+ });
+
+ /**
+ * @route GET /icons/:icon
+ * @desc Get specific icon SVG
+ * @param icon - Icon name
+ * @query color - Icon color (optional)
+ */
+ router.get('/:icon', async (req, res) => {
+ await iconsController.getIcon(req, res);
+ });
+
+ return router;
+}
diff --git a/src/modules/icons/icons.service.ts b/src/modules/icons/icons.service.ts
new file mode 100644
index 0000000..bd75e2f
--- /dev/null
+++ b/src/modules/icons/icons.service.ts
@@ -0,0 +1,427 @@
+/**
+ * Icons Service
+ * Business logic for icon management and retrieval
+ */
+
+import fs from 'fs/promises';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { createHash } from 'crypto';
+import { createLogger } from '../../shared/logs/logger.js';
+import { isValidCssColor } from '../../shared/utils/css-color.js';
+import type { IconCache, IconCollectionOptions, IconEffect, IconSize } from './icons.types.js';
+
+const logger = createLogger({ service: 'IconsService' });
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+export class IconsService {
+ private readonly iconsDir: string;
+ private iconsCache: string[] | null = null;
+ private svgCache: Map;
+ private pendingLoads: Map>;
+ private readonly MAX_CACHE_ITEMS = 2000;
+ private readonly HTTP_CACHE_CONTROL = 'public, max-age=31536000, immutable';
+ private readonly ICON_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
+ private readonly COLLECTION_FALLBACK_COLORS = [
+ '#0088CC',
+ '#3178C6',
+ '#FFFFFF',
+ '#38B2AC',
+ '#F59E0B',
+ '#EF4444',
+ '#22C55E',
+ '#A78BFA',
+ '#06B6D4',
+ ] as const;
+
+ private readonly ICON_ALIASES: Record = {
+ nodedotjs: 'nodejs',
+ node: 'nodejs',
+ vue: 'vuedotjs',
+ ember: 'emberdotjs',
+ emberjs: 'emberdotjs',
+ three: 'threedotjs',
+ threejs: 'threedotjs',
+ chart: 'chartdotjs',
+ chartjs: 'chartdotjs',
+ fly: 'flydotio',
+ flyio: 'flydotio',
+ devto: 'devdotto',
+ gitignoredotio: 'gitignoredotio',
+ gitignore: 'gitignoredotio',
+ js: 'javascript',
+ ts: 'typescript',
+ py: 'python',
+ cpp: 'cplusplus',
+ 'c++': 'cplusplus',
+ cs: 'csharp',
+ postgres: 'postgresql',
+ golang: 'go',
+ };
+
+ private resolveIconName(name: string): string {
+ const normalized = name.toLowerCase();
+ return this.ICON_ALIASES[normalized] || normalized;
+ }
+
+ constructor() {
+ this.iconsDir = path.join(__dirname, '..', '..', '..', 'public', 'assets', 'icons');
+ this.svgCache = new Map();
+ this.pendingLoads = new Map();
+ }
+
+ /**
+ * Load and cache icon list
+ */
+ async loadIconsList(): Promise {
+ if (!this.iconsCache) {
+ const files = await fs.readdir(this.iconsDir);
+ this.iconsCache = files
+ .filter(file => file.endsWith('.svg'))
+ .map(file => file.replace('.svg', ''));
+
+ logger.info('Icons list loaded', { count: this.iconsCache.length });
+ }
+ return this.iconsCache;
+ }
+
+ /**
+ * Get icon SVG content
+ */
+ async getIcon(iconName: string, color?: string): Promise<{ content: string; etag: string }> {
+ // Validate icon name
+ if (!this.isValidIconName(iconName)) {
+ throw new Error('Invalid icon name');
+ }
+
+ // Validate color if provided
+ if (color && !this.isValidColor(color)) {
+ throw new Error('Invalid color format');
+ }
+
+ const cacheKey = color ? `${iconName}-${color}` : iconName;
+
+ // Check cache
+ const cached = this.svgCache.get(cacheKey);
+ if (cached) {
+ logger.debug('Returning cached icon', { iconName, color });
+ return { content: cached.content, etag: cached.etag };
+ }
+
+ // Check pending loads
+ const pending = this.pendingLoads.get(cacheKey);
+ if (pending) {
+ const content = await pending;
+ const cachedPending = this.svgCache.get(cacheKey);
+ return { content: cachedPending!.content, etag: cachedPending!.etag };
+ }
+
+ // Load icon
+ const promise = this.loadIcon(iconName, color);
+ this.pendingLoads.set(cacheKey, promise);
+
+ try {
+ const content = await promise;
+ const etag = this.createWeakEtag(content);
+
+ this.svgCache.set(cacheKey, { content, etag, timestamp: Date.now() });
+ this.maybePruneCache();
+
+ return { content, etag };
+ } finally {
+ this.pendingLoads.delete(cacheKey);
+ }
+ }
+
+ /**
+ * Get SVG content for multiple icons combined in a single image.
+ */
+ async getIconCollection(options: IconCollectionOptions): Promise<{ content: string; etag: string }> {
+ if (options.iconNames.length === 0) {
+ throw new Error('name is required');
+ }
+
+ if (options.size !== 'small' && options.size !== 'medium' && options.size !== 'large') {
+ throw new Error('Invalid size. Supported: small, medium, large');
+ }
+
+ if (options.effect !== undefined && options.effect !== 'glow' && options.effect !== 'wave') {
+ throw new Error('Invalid effect. Supported: glow, wave');
+ }
+
+ if (!Number.isInteger(options.columns) || options.columns < 1 || options.columns > 40) {
+ throw new Error('Invalid columns. Supported range: 1-40');
+ }
+
+ const cacheKey = `collection:${options.iconNames.join(',')}:${options.size}:${options.effect || 'none'}:${options.columns}:${(options.colors || []).join(',')}`;
+ const cached = this.svgCache.get(cacheKey);
+
+ if (cached) {
+ logger.debug('Returning cached icon collection', {
+ count: options.iconNames.length,
+ size: options.size,
+ effect: options.effect,
+ columns: options.columns,
+ });
+ return { content: cached.content, etag: cached.etag };
+ }
+
+ const resolvedColors = options.iconNames.map((_, index) => {
+ const mappedColor = options.colors?.[index];
+ return mappedColor || this.COLLECTION_FALLBACK_COLORS[index % this.COLLECTION_FALLBACK_COLORS.length];
+ });
+
+ const icons = await Promise.all(options.iconNames.map(async (iconName, index) => {
+ try {
+ const icon = await this.getIcon(iconName, resolvedColors[index]);
+ return icon.content;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (message.includes('Invalid')) {
+ throw error;
+ }
+ throw new Error(`Icon not found: ${iconName}`);
+ }
+ }));
+
+ const content = this.combineIcons(icons, {
+ size: options.size,
+ effect: options.effect,
+ columns: options.columns,
+ });
+ const etag = this.createWeakEtag(content);
+
+ this.svgCache.set(cacheKey, { content, etag, timestamp: Date.now() });
+ this.maybePruneCache();
+
+ return { content, etag };
+ }
+
+ private combineIcons(
+ icons: string[],
+ options: { size: IconSize; effect?: IconEffect; columns: number },
+ ): string {
+ const scale = this.getCollectionScale(options.size);
+ const gap = this.getCollectionGap(options.size);
+ const columnCount = options.columns;
+ const rowCount = Math.ceil(icons.length / columnCount);
+ const actualColumnCount = Math.min(columnCount, icons.length);
+
+ const svgParts = icons.map((icon, index) => this.extractSvgParts(icon, `icon-${index}`));
+ const widths = new Array(actualColumnCount).fill(0);
+ const heights = new Array(rowCount).fill(0);
+
+ svgParts.forEach((part, index) => {
+ const row = Math.floor(index / columnCount);
+ const col = index % columnCount;
+ const scaledWidth = Math.ceil(part.width * scale);
+ const scaledHeight = Math.ceil(part.height * scale);
+
+ if (col < actualColumnCount) {
+ widths[col] = Math.max(widths[col], scaledWidth);
+ }
+ heights[row] = Math.max(heights[row], scaledHeight);
+ });
+
+ const xOffsets: number[] = [];
+ let xCursor = 0;
+ for (let i = 0; i < actualColumnCount; i++) {
+ xOffsets.push(xCursor);
+ xCursor += widths[i] + (i < actualColumnCount - 1 ? gap : 0);
+ }
+
+ const yOffsets: number[] = [];
+ let yCursor = 0;
+ for (let i = 0; i < rowCount; i++) {
+ yOffsets.push(yCursor);
+ yCursor += heights[i] + (i < rowCount - 1 ? gap : 0);
+ }
+
+ const totalWidth = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, actualColumnCount - 1) * gap;
+ const totalHeight = heights.reduce((sum, height) => sum + height, 0) + Math.max(0, rowCount - 1) * gap;
+
+ const defs = options.effect === 'glow'
+ ? ' '
+ : '';
+
+ const groups = svgParts.map((part, index) => {
+ const row = Math.floor(index / columnCount);
+ const col = index % columnCount;
+ const x = xOffsets[col];
+ const y = yOffsets[row];
+ const filterAttr = options.effect === 'glow' ? ' filter="url(#icons-collection-glow)"' : '';
+ const animatedTransform = options.effect === 'wave'
+ ? ` `
+ : '';
+ const transformAttr = options.effect === 'wave' ? '' : ` transform="translate(${x} ${y})"`;
+
+ return `${animatedTransform}${part.content} `;
+ }).join('');
+
+ return `${defs}${groups} `;
+ }
+
+ private extractSvgParts(svg: string, suffix: string): { width: number; height: number; content: string } {
+ const width = this.readSvgDimension(svg, 'width');
+ const height = this.readSvgDimension(svg, 'height');
+ const [viewBoxWidth, viewBoxHeight] = this.readViewBox(svg);
+ const content = svg
+ .replace(/^\s*]*>/i, '')
+ .replace(/<\/svg>\s*$/i, '');
+
+ return {
+ width: width || viewBoxWidth || 24,
+ height: height || viewBoxHeight || 24,
+ content: this.namespaceIds(content, suffix),
+ };
+ }
+
+ private readSvgDimension(svg: string, attribute: 'width' | 'height'): number | null {
+ const match = svg.match(new RegExp(`\\b${attribute}="([0-9]+(?:\\.[0-9]+)?)"`, 'i'));
+ if (!match) {
+ return null;
+ }
+
+ return Number.parseFloat(match[1]);
+ }
+
+ private readViewBox(svg: string): [number, number] {
+ const match = svg.match(/\bviewBox="[^\"]*\s([0-9]+(?:\.[0-9]+)?)\s([0-9]+(?:\.[0-9]+)?)"/i);
+ if (!match) {
+ return [0, 0];
+ }
+
+ return [Number.parseFloat(match[1]), Number.parseFloat(match[2])];
+ }
+
+ private namespaceIds(content: string, suffix: string): string {
+ const ids = Array.from(content.matchAll(/\bid="([^"]+)"/g), (match) => match[1]);
+
+ return ids.reduce((output, id) => {
+ const nextId = `${id}-${suffix}`;
+ return output
+ .split(`id="${id}"`).join(`id="${nextId}"`)
+ .split(`url(#${id})`).join(`url(#${nextId})`)
+ .split(`href="#${id}"`).join(`href="#${nextId}"`)
+ .split(`xlink:href="#${id}"`).join(`xlink:href="#${nextId}"`);
+ }, content);
+ }
+
+ private getCollectionScale(size: IconSize): number {
+ switch (size) {
+ case 'small':
+ return 0.82;
+ case 'large':
+ return 1.35;
+ default:
+ return 1;
+ }
+ }
+
+ private getCollectionGap(size: IconSize): number {
+ switch (size) {
+ case 'small':
+ return 8;
+ case 'large':
+ return 16;
+ default:
+ return 12;
+ }
+ }
+
+ /**
+ * Load icon from file system
+ */
+ private async loadIcon(iconName: string, color?: string): Promise {
+ const targetName = this.resolveIconName(iconName);
+ const iconPath = path.join(this.iconsDir, `${targetName}.svg`);
+
+ // Verify path doesn't escape icons directory
+ const resolvedPath = path.resolve(iconPath);
+ const resolvedIconsDir = path.resolve(this.iconsDir);
+
+ if (!resolvedPath.startsWith(resolvedIconsDir)) {
+ throw new Error('Invalid icon path');
+ }
+
+ let content = await fs.readFile(iconPath, 'utf-8');
+
+ // Apply color if specified
+ if (color) {
+ content = this.applyColor(content, color);
+ }
+
+ logger.debug('Icon loaded', { iconName, color });
+ return content;
+ }
+
+ /**
+ * Apply color to SVG content
+ */
+ private applyColor(svg: string, color: string): string {
+ // Replace fill and stroke attributes with the specified color
+ return svg
+ .replace(/fill="[^"]*"/g, `fill="${color}"`)
+ .replace(/stroke="[^"]*"/g, `stroke="${color}"`);
+ }
+
+ /**
+ * Validate icon name
+ */
+ private isValidIconName(name: string): boolean {
+ return this.ICON_NAME_REGEX.test(name);
+ }
+
+ /**
+ * Validate color parameter (M2: structured parsers + named-color allowlist).
+ */
+ private isValidColor(color: string): boolean {
+ return isValidCssColor(color);
+ }
+
+ /**
+ * Create weak ETag from SVG content
+ */
+ private createWeakEtag(content: string): string {
+ const hash = createHash('sha1').update(content).digest('base64url');
+ return `W/"${hash}"`;
+ }
+
+ /**
+ * Prune SVG cache to prevent unbounded memory growth
+ */
+ private maybePruneCache(): void {
+ if (this.svgCache.size <= this.MAX_CACHE_ITEMS) {
+ return;
+ }
+
+ const overflowCount = this.svgCache.size - this.MAX_CACHE_ITEMS;
+ let removed = 0;
+
+ for (const key of this.svgCache.keys()) {
+ this.svgCache.delete(key);
+ removed += 1;
+ if (removed >= overflowCount) break;
+ }
+
+ logger.debug('Cache pruned', { removed, remaining: this.svgCache.size });
+ }
+
+ /**
+ * Get cache control header
+ */
+ getCacheControl(): string {
+ return this.HTTP_CACHE_CONTROL;
+ }
+
+ /**
+ * Clear cache
+ */
+ clearCache(): void {
+ this.svgCache.clear();
+ this.iconsCache = null;
+ logger.info('Icons cache cleared');
+ }
+}
diff --git a/src/modules/icons/icons.types.ts b/src/modules/icons/icons.types.ts
new file mode 100644
index 0000000..dd2db77
--- /dev/null
+++ b/src/modules/icons/icons.types.ts
@@ -0,0 +1,40 @@
+/**
+ * Icons Module Types
+ * Type definitions for icon features
+ */
+
+export interface IconQueryParams {
+ color?: string;
+ size?: string;
+}
+
+export type IconEffect = 'wave' | 'glow';
+
+export type IconSize = 'small' | 'medium' | 'large';
+
+export interface IconCollectionQueryParams {
+ name?: string;
+ color?: string;
+ size?: string;
+ effect?: string;
+ columns?: string;
+}
+
+export interface IconCollectionOptions {
+ iconNames: string[];
+ colors?: string[];
+ size: IconSize;
+ effect?: IconEffect;
+ columns: number;
+}
+
+export interface IconCache {
+ content: string;
+ etag: string;
+ timestamp: number;
+}
+
+export interface IconCollectionParams {
+ collection?: string;
+ theme?: string;
+}
diff --git a/src/modules/icons/index.ts b/src/modules/icons/index.ts
new file mode 100644
index 0000000..b0006e6
--- /dev/null
+++ b/src/modules/icons/index.ts
@@ -0,0 +1,18 @@
+/**
+ * Icons Module
+ * Exports all icon-related functionality
+ */
+
+export { IconsController } from './icons.controller.js';
+export { IconsCollectionController } from './icons-collection.controller.js';
+export { IconsService } from './icons.service.js';
+export { createIconsRouter } from './icons.routes.js';
+export type {
+ IconQueryParams,
+ IconCache,
+ IconCollectionParams,
+ IconCollectionQueryParams,
+ IconCollectionOptions,
+ IconEffect,
+ IconSize,
+} from './icons.types.js';
diff --git a/src/modules/languages/index.ts b/src/modules/languages/index.ts
new file mode 100644
index 0000000..dbb0415
--- /dev/null
+++ b/src/modules/languages/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Languages Module
+ * Exports all language-related functionality
+ */
+
+export { LanguagesController } from './languages.controller.js';
+export { LanguagesService } from './languages.service.js';
+export { createLanguagesRouter } from './languages.routes.js';
+export type {
+ LanguageQueryParams,
+ LanguageData,
+ LanguageCache,
+ LanguageCardOptions,
+ LanguagePieOptions
+} from './languages.types.js';
diff --git a/src/modules/languages/languages.controller.ts b/src/modules/languages/languages.controller.ts
new file mode 100644
index 0000000..272b95c
--- /dev/null
+++ b/src/modules/languages/languages.controller.ts
@@ -0,0 +1,81 @@
+/**
+ * Languages Controller
+ * Handles HTTP requests for language statistics
+ */
+
+import { Request, Response } from 'express';
+import { LanguagesService } from './languages.service.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { LanguagesQuery } from '../../shared/validations/validation.js';
+import type { LanguageQueryParams } from './languages.types.js';
+
+const logger = createLogger({ controller: 'LanguagesController' });
+
+export class LanguagesController {
+ private languagesService: LanguagesService;
+
+ static routeDocs = {
+ requiredParams: ['username'],
+ optionalParams: [
+ 'type',
+ 'theme',
+ 'show_info',
+ 'info_outline',
+ 'size'
+ ],
+ payload: null as null,
+ example: '/languages?username=pphatdev&type=card&theme=default'
+ };
+
+ constructor(languagesService: LanguagesService) {
+ this.languagesService = languagesService;
+ }
+
+ /**
+ * Get language visualization as SVG
+ */
+ async getSvg(req: Request, res: Response): Promise {
+ const startTime = Date.now();
+
+ try {
+ const params = this.readValidated(req);
+
+ // Generate visualization
+ const svg = await this.languagesService.generateLanguageVisualization(params);
+
+ const duration = Date.now() - startTime;
+ logger.info('Language visualization generated', {
+ username: params.username,
+ type: params.type,
+ duration
+ });
+
+ res.setHeader('Content-Type', 'image/svg+xml');
+ res.setHeader('Cache-Control', 'public, max-age=600');
+ res.send(svg);
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ logger.error('Failed to generate language visualization', error as Error, { duration });
+ // Never echo raw error messages to clients โ they may leak internal
+ // paths, GitHub-token hints from rate-limit errors, or DB details.
+ res.status(500).send('Failed to generate language visualization');
+ }
+ }
+
+ /**
+ * Read the Zod-validated query. `validate(languagesQuerySchema, 'query')`
+ * attaches `req.validated`; controller-level defaults for backward
+ * compatibility are applied here.
+ */
+ private readValidated(req: Request): LanguageQueryParams {
+ const v = (req as Request & { validated?: LanguagesQuery }).validated ?? ({} as LanguagesQuery);
+ return {
+ username: v.username as string,
+ type: v.type ?? 'card',
+ theme: v.theme ?? 'default',
+ show_info: v.show_info,
+ info_outline: v.info_outline ?? 'solid',
+ size: v.size,
+ };
+ }
+}
diff --git a/src/modules/languages/languages.routes.ts b/src/modules/languages/languages.routes.ts
new file mode 100644
index 0000000..11638b9
--- /dev/null
+++ b/src/modules/languages/languages.routes.ts
@@ -0,0 +1,39 @@
+/**
+ * Languages Routes
+ * Defines HTTP routes for language endpoints
+ */
+
+import { Router } from 'express';
+import { LanguagesController } from './languages.controller.js';
+import { LanguagesService } from './languages.service.js';
+import { GitHubClient } from '../../shared/utils/github-client.js';
+import { validate } from '../../shared/middlewares/error.middleware.js';
+import { languagesQuerySchema } from '../../shared/validations/validation.js';
+import type { ResponseCache } from '../../shared/utils/response-cache.js';
+
+export function createLanguagesRouter(
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number
+): Router {
+ const router = Router();
+
+ // Initialize service and controller
+ const languagesService = new LanguagesService(githubClient, cache, cacheDuration);
+ const languagesController = new LanguagesController(languagesService);
+
+ /**
+ * @route GET /languages
+ * @desc Get GitHub user language statistics visualization
+ * @query username - GitHub username (required)
+ * @query type - Visualization type: card, pie (default: card)
+ * @query theme - Color theme (default: default)
+ * @query show_info - Show info panel (default: true)
+ * @query info_outline - Info outline style: solid, frame (default: solid)
+ */
+ router.get('/', validate(languagesQuerySchema, 'query'), async (req, res) => {
+ await languagesController.getSvg(req, res);
+ });
+
+ return router;
+}
diff --git a/src/modules/languages/languages.service.ts b/src/modules/languages/languages.service.ts
new file mode 100644
index 0000000..e48cd20
--- /dev/null
+++ b/src/modules/languages/languages.service.ts
@@ -0,0 +1,99 @@
+/**
+ * Languages Service
+ * Business logic for GitHub language statistics
+ */
+
+import { GitHubClient } from '../../shared/utils/github-client.js';
+import { LanguageCardRenderer } from '../../shared/components/language-card.js';
+import { LanguagePieChartRenderer } from '../../shared/components/language-pie-chart.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { LanguageQueryParams, LanguageCache } from './languages.types.js';
+import type { LanguageCount } from '../../shared/types/language.types.js';
+import type { ResponseCache } from '../../shared/utils/response-cache.js';
+
+const logger = createLogger({ service: 'LanguagesService' });
+
+export class LanguagesService {
+ private githubClient: GitHubClient;
+ private cache: ResponseCache;
+ private readonly cacheDuration: number;
+
+ constructor(
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number
+ ) {
+ this.githubClient = githubClient;
+ this.cache = cache;
+ this.cacheDuration = cacheDuration;
+ }
+
+ /**
+ * Generate language visualization
+ */
+ async generateLanguageVisualization(params: LanguageQueryParams): Promise {
+ const cacheKey = this.getCacheKey(params);
+
+ // Check cache
+ const cached = this.cache.get(cacheKey);
+ if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
+ logger.debug('Returning cached language visualization', { username: params.username });
+ return cached.data;
+ }
+
+ // Fetch language data
+ const languages = await this.githubClient.fetchUserLanguages(params.username);
+
+ // Generate visualization based on type
+ const svg = params.type === 'pie'
+ ? this.generatePieChart(languages, params)
+ : this.generateCard(languages, params);
+
+ // Cache result
+ this.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
+
+ logger.info('Language visualization generated', {
+ username: params.username,
+ type: params.type
+ });
+
+ return svg;
+ }
+
+ /**
+ * Generate language card
+ */
+ private generateCard(languages: LanguageCount[], params: LanguageQueryParams): string {
+ return LanguageCardRenderer.generateLanguagesCard(languages, {
+ theme: params.theme || 'default',
+ showInfo: params.show_info !== 'false',
+ dataBorderStyle: params.info_outline === 'frame' ? 'frame' : 'solid',
+ size: params.size,
+ });
+ }
+
+ /**
+ * Generate language pie chart
+ */
+ private generatePieChart(languages: LanguageCount[], params: LanguageQueryParams): string {
+ return LanguagePieChartRenderer.generatePieChart(languages, {
+ theme: params.theme || 'default',
+ size: params.size,
+ });
+ }
+
+ /**
+ * Get cache key for parameters
+ */
+ private getCacheKey(params: LanguageQueryParams): string {
+ return `languages-${params.username}-${params.type}-${params.theme}-${params.show_info}-${params.info_outline}`;
+ }
+
+ /**
+ * Clear cache
+ */
+ clearCache(): void {
+ this.cache.clear();
+ logger.info('Languages cache cleared');
+ }
+}
diff --git a/src/modules/languages/languages.types.ts b/src/modules/languages/languages.types.ts
new file mode 100644
index 0000000..78bd6be
--- /dev/null
+++ b/src/modules/languages/languages.types.ts
@@ -0,0 +1,35 @@
+/**
+ * Languages Module Types
+ * Type definitions for the languages feature
+ */
+
+export interface LanguageQueryParams {
+ username: string;
+ type?: 'card' | 'pie';
+ theme?: string;
+ show_info?: string;
+ info_outline?: 'solid' | 'frame';
+ size?: 'small' | 'medium' | 'large' | 'default';
+}
+
+export interface LanguageData {
+ name: string;
+ percentage: number;
+ color: string;
+ bytes: number;
+}
+
+export interface LanguageCache {
+ data: string;
+ timestamp: number;
+}
+
+export interface LanguageCardOptions {
+ theme: string;
+ showInfo: boolean;
+ dataBorderStyle: 'solid' | 'frame';
+}
+
+export interface LanguagePieOptions {
+ theme: string;
+}
diff --git a/src/modules/stats/index.ts b/src/modules/stats/index.ts
new file mode 100644
index 0000000..2d5b338
--- /dev/null
+++ b/src/modules/stats/index.ts
@@ -0,0 +1,33 @@
+/**
+ * Stats Module
+ * Exports all stats-related functionality
+ */
+
+import { Router } from 'express';
+import { StatsController } from './stats.controller.js';
+import { StatsService } from './stats.service.js';
+import type { GitHubClient } from '../../shared/utils/github-client.js';
+import { validate } from '../../shared/middlewares/error.middleware.js';
+import { statsQuerySchema } from '../../shared/validations/validation.js';
+import type { ResponseCache } from '../../shared/utils/response-cache.js';
+
+export { StatsController } from './stats.controller.js';
+export { StatsService } from './stats.service.js';
+export type { StatsQueryParams, StatsCardOptions } from './stats.types.js';
+
+/**
+ * Factory function to create stats router with dependencies
+ */
+export function createStatsRouter(
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number
+): Router {
+ const router = Router();
+ const statsService = new StatsService(githubClient, cache, cacheDuration);
+ const statsController = new StatsController(statsService);
+
+ router.get('/', validate(statsQuerySchema, 'query'), (req, res) => statsController.getStats(req, res));
+
+ return router;
+}
diff --git a/src/modules/stats/stats.controller.ts b/src/modules/stats/stats.controller.ts
new file mode 100644
index 0000000..ade4cde
--- /dev/null
+++ b/src/modules/stats/stats.controller.ts
@@ -0,0 +1,56 @@
+/**
+ * Stats Module - Controller
+ * Handles user statistics card generation
+ */
+
+import { Request, Response } from 'express';
+import { StatsService } from './stats.service.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { StatsQuery } from '../../shared/validations/validation.js';
+import type { StatsQueryParams } from './stats.types.js';
+
+const logger = createLogger({ service: 'StatsController' });
+
+export class StatsController {
+ private statsService: StatsService;
+
+ constructor(statsService: StatsService) {
+ this.statsService = statsService;
+ }
+
+ async getStats(req: Request, res: Response): Promise {
+ try {
+ // Populated by `validate(statsQuerySchema, 'query')` at the route.
+ // Zod has already enforced username shape, enum values, and hex colors
+ // (normalized to `#โฆ` form).
+ const params = (req as Request & { validated?: StatsQuery }).validated as StatsQueryParams;
+
+ // Determine format based on user agent
+ const userAgent = req.get('user-agent') || '';
+ const isPreviewBot = /discordbot|twitterbot|slackbot|facebookexternalhit|linkedinbot|telegrambot|telegram|mastodon|whatsapp/i.test(userAgent);
+ const format = params.format?.toLowerCase() || (isPreviewBot ? 'webp' : 'svg');
+
+ // Generate SVG
+ const svg = await this.statsService.generateSvg(params);
+
+ // Convert to WebP if requested
+ if (format === 'webp') {
+ const cacheKey = JSON.stringify(params) + '|webp';
+ const webpBuffer = await this.statsService.convertToWebp(svg, cacheKey);
+ res.setHeader('Content-Type', 'image/webp');
+ res.setHeader('Cache-Control', 'public, max-age=600');
+ res.send(webpBuffer);
+ return;
+ }
+
+ // Return SVG
+ res.setHeader('Content-Type', 'image/svg+xml');
+ res.setHeader('Cache-Control', 'public, max-age=600');
+ res.send(svg);
+
+ } catch (error) {
+ logger.error('Failed to generate stats', error as Error);
+ res.status(500).send('Failed to generate stats');
+ }
+ }
+}
diff --git a/src/modules/stats/stats.routes.ts b/src/modules/stats/stats.routes.ts
new file mode 100644
index 0000000..de32bee
--- /dev/null
+++ b/src/modules/stats/stats.routes.ts
@@ -0,0 +1,6 @@
+/**
+ * Stats Module - Routes
+ * Re-exports the createStatsRouter factory function
+ */
+
+export { createStatsRouter } from './index.js';
diff --git a/src/modules/stats/stats.service.ts b/src/modules/stats/stats.service.ts
new file mode 100644
index 0000000..f135253
--- /dev/null
+++ b/src/modules/stats/stats.service.ts
@@ -0,0 +1,176 @@
+/**
+ * Stats Service
+ * Business logic for GitHub user statistics
+ */
+
+import { GitHubClient } from '../../shared/utils/github-client.js';
+import { CardRenderer } from '../../shared/components/card-renderer.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import type { StatsQueryParams, StatsCache, PngCache } from './stats.types.js'; import type { StatsCardOptions } from './stats.types.js';
+import type { ResponseCache } from '../../shared/utils/response-cache.js';
+
+const logger = createLogger({ service: 'StatsService' });
+let sharpLoader: Promise | null = null;
+
+async function getSharp() {
+ if (!sharpLoader) {
+ sharpLoader = import('sharp').then((module) => module.default);
+ }
+
+ return sharpLoader;
+}
+
+export class StatsService {
+ private githubClient: GitHubClient;
+ private cache: ResponseCache;
+ private pngCache: Map;
+ private pendingRequests: Map>;
+ private pendingWebpRequests: Map>;
+ private readonly cacheDuration: number;
+
+ constructor(
+ githubClient: GitHubClient,
+ cache: ResponseCache,
+ cacheDuration: number
+ ) {
+ this.githubClient = githubClient;
+ this.cache = cache;
+ this.pngCache = new Map();
+ this.pendingRequests = new Map();
+ this.pendingWebpRequests = new Map();
+ this.cacheDuration = cacheDuration;
+ }
+
+ /**
+ * Generate SVG stats card
+ */
+ async generateSvg(params: StatsQueryParams): Promise {
+ const cacheKey = JSON.stringify(params);
+
+ // Check cache
+ const cached = this.cache.get(cacheKey);
+ if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
+ logger.debug('Returning cached SVG', { username: params.username });
+ return cached.data;
+ }
+
+ // Check pending requests
+ const pending = this.pendingRequests.get(cacheKey);
+ if (pending) {
+ logger.debug('Waiting for pending request', { username: params.username });
+ return await pending;
+ }
+
+ // Generate new SVG
+ const promise = this.generateNewSvg(params);
+ this.pendingRequests.set(cacheKey, promise);
+
+ try {
+ const svg = await promise;
+ this.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
+ return svg;
+ } finally {
+ this.pendingRequests.delete(cacheKey);
+ }
+ }
+
+ /**
+ * Generate new SVG from GitHub data
+ */
+ private async generateNewSvg(params: StatsQueryParams): Promise {
+ const avatarMode = params.avatar_mode || 'none';
+ const year = this.parseYear(params.year);
+ const stats = await this.githubClient.fetchUserStats(params.username, { avatarMode, year });
+
+ const options: StatsCardOptions = {
+ theme: params.theme || 'default',
+ hideTitle: params.hide_title === 'true',
+ hideBorder: params.hide_border === 'true',
+ hideRank: params.hide_rank === 'true',
+ showIcons: params.show_icons === 'true',
+ avatarMode: avatarMode,
+ customTitle: params.custom_title,
+ dataBorderStyle: params.data_border_style as 'solid' | 'frame' || 'solid',
+ dataBorderFramePosition: params.data_border_frame as 'in' | 'out' || 'out',
+ textColor: params.textColor,
+ titleColor: params.titleColor,
+ size: params.size
+ };
+
+ return CardRenderer.generateStatsCard(stats, options);
+ }
+
+ /**
+ * Convert SVG to PNG
+ */
+ async convertToPng(svg: string): Promise {
+ const sharp = await getSharp();
+ const pngBuffer = await sharp(Buffer.from(svg))
+ .png()
+ .toBuffer();
+ return pngBuffer;
+ }
+
+ /**
+ * Convert SVG to WebP
+ */
+ async convertToWebp(svg: string, cacheKey: string): Promise {
+ // Check PNG cache first
+ const pngCached = this.pngCache.get(cacheKey);
+ if (pngCached && Date.now() - pngCached.timestamp < this.cacheDuration) {
+ return pngCached.data;
+ }
+
+ // Check pending requests
+ const pending = this.pendingWebpRequests.get(cacheKey);
+ if (pending) {
+ return await pending;
+ }
+
+ // Generate new WebP
+ const promise = this.generateWebp(svg);
+ this.pendingWebpRequests.set(cacheKey, promise);
+
+ try {
+ const webp = await promise;
+ this.pngCache.set(cacheKey, { data: webp, timestamp: Date.now() });
+ return webp;
+ } finally {
+ this.pendingWebpRequests.delete(cacheKey);
+ }
+ }
+
+ /**
+ * Generate WebP from SVG
+ */
+ private async generateWebp(svg: string): Promise {
+ const sharp = await getSharp();
+ const webpBuffer = await sharp(Buffer.from(svg))
+ .webp({ quality: 90 })
+ .toBuffer();
+ return webpBuffer;
+ }
+
+ /**
+ * Parse `?year=YYYY` into a valid year (2008+ = GitHub launch, โค current).
+ * Returns undefined for missing/invalid input so callers fall back to
+ * all-time behavior.
+ */
+ private parseYear(raw: string | undefined): number | undefined {
+ if (!raw) return undefined;
+ const y = parseInt(raw, 10);
+ if (Number.isNaN(y)) return undefined;
+ const currentYear = new Date().getUTCFullYear();
+ if (y < 2008 || y > currentYear) return undefined;
+ return y;
+ }
+
+ /**
+ * Clear all caches
+ */
+ clearCache(): void {
+ this.cache.clear();
+ this.pngCache.clear();
+ logger.info('Stats cache cleared');
+ }
+}
diff --git a/src/modules/stats/stats.types.ts b/src/modules/stats/stats.types.ts
new file mode 100644
index 0000000..38318ea
--- /dev/null
+++ b/src/modules/stats/stats.types.ts
@@ -0,0 +1,51 @@
+/**
+ * Stats Module - Types
+ */
+
+export interface StatsQueryParams {
+ username: string;
+ theme?: string;
+ hide_title?: string;
+ hide_border?: string;
+ hide_rank?: string;
+ show_icons?: string;
+ avatar_mode?: 'none' | 'avatar' | 'radar';
+ show_avatar?: string;
+ custom_title?: string;
+ data_border_style?: 'solid' | 'frame';
+ data_border_frame?: 'in' | 'out';
+ bgColor?: string;
+ borderColor?: string;
+ textColor?: string;
+ titleColor?: string;
+ format?: string;
+ size?: 'small' | 'medium' | 'large' | 'default';
+ year?: string;
+}
+
+export interface StatsCardOptions {
+ theme?: string;
+ hideTitle?: boolean;
+ hideBorder?: boolean;
+ hideRank?: boolean;
+ showIcons?: boolean;
+ avatarMode?: 'none' | 'avatar' | 'radar';
+ customTitle?: string;
+ dataBorderStyle?: 'solid' | 'frame';
+ dataBorderFramePosition?: 'in' | 'out';
+ bgColor?: string;
+ borderColor?: string;
+ textColor?: string;
+ titleColor?: string;
+ size?: 'small' | 'medium' | 'large' | 'default';
+}
+
+export interface StatsCache {
+ data: string;
+ timestamp: number;
+}
+
+export interface PngCache {
+ data: Buffer;
+ timestamp: number;
+}
diff --git a/src/modules/users/index.ts b/src/modules/users/index.ts
new file mode 100644
index 0000000..54a2fda
--- /dev/null
+++ b/src/modules/users/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Users Module
+ * Exports users listing functionality.
+ */
+
+export { UsersController } from './users.controller.js';
+export { UsersService } from './users.service.js';
+export { createUsersRouter } from './users.routes.js';
+export type { UserListItem, UserListResponse, UserListQueryParams, UserBadgeResponse } from './users.types.js';
diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts
new file mode 100644
index 0000000..de9b678
--- /dev/null
+++ b/src/modules/users/users.controller.ts
@@ -0,0 +1,79 @@
+/**
+ * Users Controller
+ * Handles HTTP requests for the users listing endpoint.
+ */
+
+import type { Request, Response } from 'express';
+import { UsersService } from './users.service.js';
+import { createLogger } from '../../shared/logs/logger.js';
+import { isValidGithubUsername } from '../../shared/utils/username.js';
+import type { UserListQueryParams } from './users.types.js';
+
+const logger = createLogger({ controller: 'UsersController' });
+
+const DEFAULT_LIMIT = 30;
+const MAX_LIMIT = 500;
+
+export class UsersController {
+ private usersService: UsersService;
+
+ constructor(usersService: UsersService) {
+ this.usersService = usersService;
+ }
+
+ async listUsers(req: Request, res: Response): Promise {
+ try {
+ const query = req.query as UserListQueryParams;
+
+ const parsedLimit = Number.parseInt(query.limit ?? '', 10);
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
+ ? Math.min(parsedLimit, MAX_LIMIT)
+ : DEFAULT_LIMIT;
+
+ // Accept either `page` (1-based) or `offset`. `page` wins when both are supplied.
+ const parsedPage = Number.parseInt(query.page ?? '', 10);
+ const parsedOffset = Number.parseInt(query.offset ?? '', 10);
+
+ let offset: number;
+ if (Number.isFinite(parsedPage) && parsedPage > 0) {
+ offset = (parsedPage - 1) * limit;
+ } else if (Number.isFinite(parsedOffset) && parsedOffset >= 0) {
+ offset = parsedOffset;
+ } else {
+ offset = 0;
+ }
+
+ const result = await this.usersService.listUsers(limit, offset);
+
+ res.setHeader('Cache-Control', 'public, max-age=60');
+ res.json(result);
+ } catch (error) {
+ logger.error('Failed to list users', error as Error);
+ res.status(500).json({ error: 'Failed to list users' });
+ }
+ }
+
+ async getUserBadge(req: Request, res: Response): Promise {
+ const username = (req.params.username ?? '').trim();
+
+ if (!isValidGithubUsername(username)) {
+ res.status(400).json({ error: 'Invalid username' });
+ return;
+ }
+
+ try {
+ const badge = await this.usersService.getUserBadge(username);
+
+ if (!badge) {
+ res.status(404).json({ error: 'Badge not found for user', username });
+ return;
+ }
+
+ res.setHeader('Cache-Control', 'public, max-age=60');
+ res.json(badge);
+ } catch (error) {
+ logger.error('Failed to get user badge', error as Error, { username });
+ res.status(500).json({ error: 'Failed to get user badge' });
+ }
+ }
+}
diff --git a/src/modules/users/users.routes.ts b/src/modules/users/users.routes.ts
new file mode 100644
index 0000000..575be1a
--- /dev/null
+++ b/src/modules/users/users.routes.ts
@@ -0,0 +1,33 @@
+/**
+ * Users Routes
+ * Defines HTTP routes for the users listing endpoint.
+ */
+
+import { Router } from 'express';
+import { UsersController } from './users.controller.js';
+import { UsersService } from './users.service.js';
+
+export function createUsersRouter(): Router {
+ const router = Router();
+
+ const usersService = new UsersService();
+ const usersController = new UsersController(usersService);
+
+ /**
+ * @route GET /users
+ * @desc List all usernames tracked by the stats service with GitHub avatar URLs
+ */
+ router.get('/', async (req, res) => {
+ await usersController.listUsers(req, res);
+ });
+
+ /**
+ * @route GET /users/:username/badge
+ * @desc Return the stored badge counters for a single user from the `badges` table
+ */
+ router.get('/:username/badge', async (req, res) => {
+ await usersController.getUserBadge(req, res);
+ });
+
+ return router;
+}
diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts
new file mode 100644
index 0000000..47b8b6c
--- /dev/null
+++ b/src/modules/users/users.service.ts
@@ -0,0 +1,88 @@
+/**
+ * Users Service
+ * Aggregates distinct usernames tracked by the stats_requests table
+ * and enriches them with GitHub avatar URLs.
+ */
+
+import { desc, eq, sql } from 'drizzle-orm';
+import { db } from '../../db/index.js';
+import { badges, statsRequests } from '../../db/schema.js';
+import type { UserBadgeResponse, UserListItem, UserListResponse } from './users.types.js';
+
+export class UsersService {
+ async listUsers(limit: number, offset: number): Promise {
+ const countColumn = sql`count(*)`.as('request_count');
+ const lastUsedColumn = sql`max(${statsRequests.created_at})`.as('last_used_at');
+
+ const rows = await db
+ .select({
+ username: statsRequests.username,
+ request_count: countColumn,
+ last_used_at: lastUsedColumn,
+ })
+ .from(statsRequests)
+ .groupBy(statsRequests.username)
+ .orderBy(desc(countColumn))
+ .limit(limit)
+ .offset(offset);
+
+ const totalRow = await db
+ .select({ total: sql`count(distinct ${statsRequests.username})` })
+ .from(statsRequests);
+
+ const users: UserListItem[] = rows.map((row) => ({
+ username: row.username,
+ avatar_url: `https://github.com/${row.username}.png`,
+ request_count: Number(row.request_count ?? 0),
+ last_used_at: row.last_used_at ?? null,
+ }));
+
+ const total = Number(totalRow[0]?.total ?? 0);
+ const page = Math.floor(offset / limit) + 1;
+ const total_pages = limit > 0 ? Math.max(1, Math.ceil(total / limit)) : 1;
+ const has_next = offset + users.length < total;
+ const has_prev = offset > 0;
+
+ return {
+ total,
+ limit,
+ offset,
+ page,
+ total_pages,
+ has_next,
+ has_prev,
+ users,
+ };
+ }
+
+ async getUserBadge(username: string): Promise {
+ const rows = await db
+ .select()
+ .from(badges)
+ .where(eq(badges.username, username))
+ .limit(1);
+
+ const row = rows[0];
+ if (!row) {
+ return null;
+ }
+
+ return {
+ username: row.username,
+ avatar_url: `https://github.com/${row.username}.png`,
+ visitors: row.visitors ?? 0,
+ repositories: row.repositories ?? null,
+ organization: row.organization ?? null,
+ languages: row.languages ?? null,
+ followers: row.followers ?? null,
+ total_stars: row.total_stars ?? null,
+ total_contributors: row.total_contributors ?? null,
+ total_commits: row.total_commits ?? null,
+ total_code_reviews: row.total_code_reviews ?? null,
+ total_issues: row.total_issues ?? null,
+ total_pull_requests: row.total_pull_requests ?? null,
+ total_joined_years: row.total_joined_years ?? null,
+ updated_at: row.updated_at ?? null,
+ };
+ }
+}
diff --git a/src/modules/users/users.types.ts b/src/modules/users/users.types.ts
new file mode 100644
index 0000000..157eeb1
--- /dev/null
+++ b/src/modules/users/users.types.ts
@@ -0,0 +1,45 @@
+/**
+ * Users Module - Types
+ */
+
+export interface UserListItem {
+ username: string;
+ avatar_url: string;
+ request_count: number;
+ last_used_at: number | null;
+}
+
+export interface UserListResponse {
+ total: number;
+ limit: number;
+ offset: number;
+ page: number;
+ total_pages: number;
+ has_next: boolean;
+ has_prev: boolean;
+ users: UserListItem[];
+}
+
+export interface UserListQueryParams {
+ limit?: string;
+ offset?: string;
+ page?: string;
+}
+
+export interface UserBadgeResponse {
+ username: string;
+ avatar_url: string;
+ visitors: number;
+ repositories: number | null;
+ organization: number | null;
+ languages: number | null;
+ followers: number | null;
+ total_stars: number | null;
+ total_contributors: number | null;
+ total_commits: number | null;
+ total_code_reviews: number | null;
+ total_issues: number | null;
+ total_pull_requests: number | null;
+ total_joined_years: number | null;
+ updated_at: number | null;
+}
diff --git a/src/routes/badge-cache.routes.ts b/src/routes/badge-cache.routes.ts
deleted file mode 100644
index 2a14610..0000000
--- a/src/routes/badge-cache.routes.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * Badge Cache Health Check Route
- * Provides monitoring and debugging endpoints for cache performance
- */
-
-import { Request, Response, Application } from 'express';
-import { getCacheStats, CACHE_TTL_STRATEGIES } from '../utils/badge-cache-manager.js';
-import { createLogger } from '../common/logger.js';
-
-const logger = createLogger({ service: 'BadgeCacheHealthCheck' });
-
-/**
- * Register badge cache health check routes
- */
-export function registerBadgeCacheRoutes(app: Application): void {
- /**
- * GET /cache/health - Check badge cache health status
- * Returns detailed information about badge cache state
- */
- app.get('/cache/health', async (req: Request, res: Response) => {
- try {
- const stats = await getCacheStats();
-
- res.json({
- status: stats?.health === 'healthy' ? 'ok' : 'degraded',
- badge_cache: {
- connected: stats?.connected ?? false,
- health: stats?.health ?? 'offline',
- db_size: stats?.dbSize ?? 0,
- memory: stats?.memory ?? 'unknown',
- },
- cache_strategies: {
- description: 'TTL strategies for different badge types (in seconds)',
- strategies: CACHE_TTL_STRATEGIES,
- },
- timestamp: new Date().toISOString(),
- });
- } catch (error) {
- logger.error('Health check failed', error as Error);
- res.status(500).json({
- status: 'error',
- error: 'Health check failed',
- timestamp: new Date().toISOString(),
- });
- }
- });
-
- /**
- * GET /cache/stats - Detailed cache statistics
- * Returns performance metrics and usage information
- */
- app.get('/cache/stats', async (req: Request, res: Response) => {
- try {
- const stats = await getCacheStats();
-
- res.json({
- cache_statistics: {
- connected: stats?.connected ?? false,
- db_size: stats?.dbSize ?? 0,
- memory_usage: stats?.memory ?? 'unknown',
- health: stats?.health ?? 'offline',
- },
- optimization_notes: {
- redis_benefits: [
- 'Persistent SVG cache across server restarts',
- 'Distributed cache for multi-worker deployments',
- 'Automatic TTL-based eviction reduces memory overhead',
- 'Intelligent TTL management based on data freshness',
- ],
- caching_layers: [
- {
- layer: 'Redis',
- scope: 'Persistent - survives restarts',
- ttl: 'Adaptive (1min-2hrs based on data)',
- use_case: 'Primary cache for rendered SVGs',
- },
- {
- layer: 'In-Memory Map',
- scope: 'Process memory',
- ttl: '600-3600 seconds',
- use_case: 'Secondary cache for request deduplication',
- },
- {
- layer: 'Database',
- scope: 'SQLite persistence',
- ttl: '2 hours max',
- use_case: 'Source of truth for badge values',
- },
- ],
- },
- recommendations: {
- if_memory_high: 'Reduce TTL values for less-critical badges',
- if_cache_misses_high: 'Increase TTL values or enable warmup',
- if_disconnected: 'Check Redis connection - falling back to in-memory',
- },
- timestamp: new Date().toISOString(),
- });
- } catch (error) {
- logger.error('Stats request failed', error as Error);
- res.status(500).json({
- error: 'Failed to retrieve cache statistics',
- timestamp: new Date().toISOString(),
- });
- }
- });
-
- /**
- * POST /cache/clear - Clear badge cache (admin only)
- * Useful for forcing cache refresh during maintenance
- */
- app.post('/cache/clear', async (req: Request, res: Response) => {
- // In production, you'd want to add authentication here
- // For now, this is a basic endpoint
- try {
- logger.info('Cache clear requested');
- res.json({
- message: 'Cache invalidation strategies available via API',
- methods: {
- user_badge: 'POST /cache/invalidate/user/:username',
- project_badge: 'POST /cache/invalidate/project/:owner/:repo',
- },
- note: 'Use specific methods to invalidate individual user/project caches',
- timestamp: new Date().toISOString(),
- });
- } catch (error) {
- logger.error('Cache clear failed', error as Error);
- res.status(500).json({
- error: 'Failed to clear cache',
- timestamp: new Date().toISOString(),
- });
- }
- });
-
- logger.info('Badge cache health check routes registered');
-}
diff --git a/src/routes/docs.routes.ts b/src/routes/docs.routes.ts
index 373f092..df7b42e 100644
--- a/src/routes/docs.routes.ts
+++ b/src/routes/docs.routes.ts
@@ -1,9 +1,4 @@
-import { GraphController } from "../controllers/graph.js";
-import { LanguageController } from "../controllers/languages.js";
-import { StatsController } from "../controllers/stats.js";
-import { getProjectBadgeRouteDocs } from "./project-badge.routes.js";
-import { getUserBadgeRouteDocs } from "./user-badge.routes.js";
-import { getIconsRouteDocs } from "./icons.routes.js";
+import type { Application } from 'express';
type RouteInfo = {
method: string;
@@ -14,21 +9,9 @@ type RouteInfo = {
example?: string;
};
-const routeDocs: Record> = {
- 'GET /stats': StatsController.routeDocs,
- 'GET /languages': LanguageController.routeDocs,
- 'GET /graph': GraphController.routeDocs,
- // User badge routes
- ...getUserBadgeRouteDocs(),
- // Project badge routes
- ...getProjectBadgeRouteDocs(),
- // Icons routes
- ...getIconsRouteDocs(),
-};
-
-export const getRoutes = (app: Express.Application): RouteInfo[] => {
+export const getRoutes = (app: Application): RouteInfo[] => {
const routes: RouteInfo[] = [];
- const router = (app as { _router?: { stack?: Array<{ route?: { path?: string; methods?: Record } } | { name?: string; handle?: { stack?: Array<{ route?: { path?: string; methods?: Record } }> } }> } })._router;
+ const router = (app as any)._router;
const stack = router?.stack ?? [];
for (const layer of stack) {
@@ -36,8 +19,10 @@ export const getRoutes = (app: Express.Application): RouteInfo[] => {
const routePath = layer.route.path ?? '';
const methods = Object.keys(layer.route.methods ?? {}).filter((method) => layer.route?.methods?.[method]);
for (const method of methods) {
- const routeKey = `${method.toUpperCase()} ${routePath}`;
- routes.push({ method: method.toUpperCase(), path: routePath, ...routeDocs[routeKey] });
+ routes.push({
+ method: method.toUpperCase(),
+ path: routePath
+ });
}
} else if ('name' in layer && layer.name === 'router' && layer.handle?.stack) {
for (const nestedLayer of layer.handle.stack) {
@@ -45,8 +30,10 @@ export const getRoutes = (app: Express.Application): RouteInfo[] => {
const routePath = nestedLayer.route.path ?? '';
const methods = Object.keys(nestedLayer.route.methods ?? {}).filter((method) => nestedLayer.route?.methods?.[method]);
for (const method of methods) {
- const routeKey = `${method.toUpperCase()} ${routePath}`;
- routes.push({ method: method.toUpperCase(), path: routePath, ...routeDocs[routeKey] });
+ routes.push({
+ method: method.toUpperCase(),
+ path: routePath
+ });
}
}
}
diff --git a/src/routes/icons.routes.ts b/src/routes/icons.routes.ts
deleted file mode 100644
index c81b348..0000000
--- a/src/routes/icons.routes.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * Icons Routes
- * Routes for icon demo and retrieval
- */
-import type { Application } from 'express';
-import { IconsController } from '../controllers/icons.controller.js';
-
-/**
- * Register icons routes
- */
-export function registerIconsRoutes(app: Application): void {
- // Demo page - must come before the :name route to avoid conflicts
- app.get('/icons/demo', IconsController.getDemoPage);
-
- // List all icons
- app.get('/icons', IconsController.getAllIcons);
-
- // Explicit .svg route for clearer route discovery/docs output
- app.get('/icons/:name.svg', IconsController.getIcon);
-
- // Get specific icon by name
- app.get('/icons/:name', IconsController.getIcon);
-}
-
-/**
- * Get route documentation for icons
- */
-export function getIconsRouteDocs(): Record {
- return {
- 'GET /icons': IconsController.routeDocs['icons-list'],
- 'GET /icons/:name.svg': IconsController.routeDocs['icons-get'],
- 'GET /icons/:name': IconsController.routeDocs['icons-get'],
- 'GET /icons/demo': IconsController.routeDocs['icons-demo'],
- };
-}
diff --git a/src/routes/project-badge.routes.ts b/src/routes/project-badge.routes.ts
deleted file mode 100644
index 0256ada..0000000
--- a/src/routes/project-badge.routes.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Project Badge Routes
- * Routes for project/repository-specific badges (require owner and repo parameters)
- */
-import type { Application } from 'express';
-import { ProjectBadgeController } from '../controllers/project-badge.controller.js';
-
-/**
- * Register project badge routes
- */
-export function registerProjectBadgeRoutes(app: Application): void {
- // Register routes under /project prefix
- app.get('/project/visitors', ProjectBadgeController.getVisitors);
- app.get('/project/stars', ProjectBadgeController.getStars);
- app.get('/project/forks', ProjectBadgeController.getForks);
- app.get('/project/watchers', ProjectBadgeController.getWatchers);
- app.get('/project/issues', ProjectBadgeController.getIssues);
- app.get('/project/prs', ProjectBadgeController.getPrs);
- app.get('/project/contributors', ProjectBadgeController.getContributors);
- app.get('/project/size', ProjectBadgeController.getSize);
-}
-
-/**
- * Get route documentation for project badges
- */
-export function getProjectBadgeRouteDocs(): Record {
- return {
- 'GET /project/visitors': ProjectBadgeController.routeDocs['repo-visitors'],
- 'GET /project/stars': ProjectBadgeController.routeDocs['repo-stars'],
- 'GET /project/forks': ProjectBadgeController.routeDocs['repo-forks'],
- 'GET /project/watchers': ProjectBadgeController.routeDocs['repo-watchers'],
- 'GET /project/issues': ProjectBadgeController.routeDocs['repo-issues'],
- 'GET /project/prs': ProjectBadgeController.routeDocs['repo-prs'],
- 'GET /project/contributors': ProjectBadgeController.routeDocs['repo-contributors'],
- 'GET /project/size': ProjectBadgeController.routeDocs['repo-size'],
- };
-}
diff --git a/src/routes/redis-cached.routes.ts b/src/routes/redis-cached.routes.ts
deleted file mode 100644
index 891d28e..0000000
--- a/src/routes/redis-cached.routes.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * Core Cached Routes
- * Routes for stats, languages, and graph with caching middleware
- */
-import type { Application, Request } from 'express';
-import { StatsController } from '../controllers/stats.js';
-import { LanguageController } from '../controllers/languages.js';
-import { GraphController } from '../controllers/graph.js';
-import { cacheMiddleware } from '../utils/cache-middleware.js';
-import { CACHE_KEYS, DEFAULT_TTL } from '../utils/redis-client.js';
-
-export function registerCachedRoutes(app: Application): void {
- const normalizeQueryParams = (query: Record): string => {
- const entries = Object.entries(query)
- .filter(([, value]) => value !== undefined)
- .map(([key, value]) => {
- if (Array.isArray(value)) {
- return [key, value.join(',')];
- }
-
- return [key, String(value)];
- })
- .sort(([a], [b]) => a.localeCompare(b));
-
- return new URLSearchParams(entries as Array<[string, string]>).toString();
- };
-
- const getStatsContentType = (req: Request) => {
- const format = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : undefined;
- const userAgent = req.get('user-agent') || '';
- const isPreviewBot = /discordbot|twitterbot|slackbot|facebookexternalhit|linkedinbot|telegrambot|telegram|mastodon|whatsapp/i.test(userAgent);
- const normalizedFormat = format ?? (isPreviewBot ? 'webp' : 'svg');
- return normalizedFormat === 'webp' ? 'image/webp' : 'image/svg+xml';
- };
-
- const getGraphContentType = (req: Request) => {
- const format = typeof req.query.as === 'string'
- ? req.query.as.toLowerCase()
- : typeof req.query.format === 'string'
- ? req.query.format.toLowerCase()
- : 'svg';
-
- if (format === 'webp') return 'image/webp';
- if (format === 'png') return 'image/png';
- return 'image/svg+xml';
- };
-
- // Cache middleware for /stats - cache by username and all params
- const statsCache = cacheMiddleware({
- keyGenerator: (req) => {
- const username = req.query.username as string;
- const params = normalizeQueryParams(req.query as Record);
- return `${CACHE_KEYS.STATS(username)}:${params}`;
- },
- responseHeaders: (req) => ({ 'Content-Type': getStatsContentType(req) }),
- ttl: DEFAULT_TTL.STATS
- });
-
- // Cache middleware for /languages - cache by username and all params
- const languagesCache = cacheMiddleware({
- keyGenerator: (req) => {
- const username = req.query.username as string;
- const params = normalizeQueryParams(req.query as Record);
- return `${CACHE_KEYS.LANGUAGES(username)}:${params}`;
- },
- responseHeaders: () => ({ 'Content-Type': 'image/svg+xml' }),
- ttl: DEFAULT_TTL.LANGUAGES
- });
-
- // Cache middleware for /graph - cache by username and all params
- const graphCache = cacheMiddleware({
- keyGenerator: (req) => {
- const username = req.query.username as string;
- const params = JSON.stringify(req.query);
- return CACHE_KEYS.GRAPH(username, params);
- },
- responseHeaders: (req) => ({ 'Content-Type': getGraphContentType(req) }),
- ttl: DEFAULT_TTL.GRAPH
- });
-
- // Main routes with caching
- app.get('/stats', statsCache, StatsController.getSvg);
- app.get('/languages', languagesCache, LanguageController.getSvg);
- app.get('/graph', graphCache, GraphController.getSvg);
-}
-
-export async function warmupRedisCache(username: string, port: string | number, protocol: string): Promise {
- const baseUrl = `${protocol}://localhost:${port}`;
- const query = `?username=${encodeURIComponent(username)}`;
- const urls = [
- `${baseUrl}/stats${query}`,
- `${baseUrl}/languages${query}`,
- `${baseUrl}/graph${query}`,
- `${baseUrl}/badge/visitors${query}`,
- `${baseUrl}/badge/repositories${query}`,
- `${baseUrl}/badge/organization${query}`,
- `${baseUrl}/badge/languages${query}`,
- `${baseUrl}/badge/followers${query}`,
- `${baseUrl}/badge/total-stars${query}`,
- `${baseUrl}/badge/total-contributors${query}`,
- `${baseUrl}/badge/total-commits${query}`,
- `${baseUrl}/badge/total-code-reviews${query}`,
- `${baseUrl}/badge/total-issues${query}`,
- `${baseUrl}/badge/total-pull-requests${query}`,
- `${baseUrl}/badge/total-joined-years${query}`,
- ];
-
- await Promise.all(
- urls.map(async (url) => {
- try {
- const response = await fetch(url);
- if (!response.ok) {
- throw new Error(`Warm-up failed: ${url} (${response.status})`);
- }
- } catch (error) {
- console.warn(`โ ๏ธ Warm-up request failed: ${url}`, error);
- }
- })
- );
-}
diff --git a/src/routes/register.routes.ts b/src/routes/register.routes.ts
deleted file mode 100644
index 5b454c9..0000000
--- a/src/routes/register.routes.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { Express } from 'express';
-import { GitHubClient } from '../utils/github-client.js';
-import { StatsController } from '../controllers/stats.js';
-import { LanguageController } from '../controllers/languages.js';
-import { GraphController } from '../controllers/graph.js';
-import { UserBadgeController } from '../controllers/user-badge.controller.js';
-import { ProjectBadgeController } from '../controllers/project-badge.controller.js';
-import { registerCachedRoutes } from './redis-cached.routes.js';
-import { registerUserBadgeRoutes } from './user-badge.routes.js';
-import { registerProjectBadgeRoutes } from './project-badge.routes.js';
-import { registerBadgeCacheRoutes } from './badge-cache.routes.js';
-import { registerIconsRoutes } from './icons.routes.js';
-
-// Cache type
-type CacheMap = Map;
-
-/**
- * Initialize all controllers with shared dependencies
- */
-export function initializeControllers(
- githubClient: GitHubClient,
- cache: CacheMap,
- cacheDuration: number
-): void {
- StatsController.initialize(githubClient, cache, cacheDuration);
- LanguageController.initialize(githubClient, cache, cacheDuration);
- GraphController.initialize(githubClient, cache, cacheDuration);
- UserBadgeController.initialize(githubClient, cache, cacheDuration);
- ProjectBadgeController.initialize(githubClient, cache, cacheDuration);
-}
-
-/**
- * Register all application routes on the given Express app.
- *
- * @param app - The Express application instance to attach routes to
- */
-export function registerRoutes(app: Express): void {
- registerCachedRoutes(app);
- registerUserBadgeRoutes(app);
- registerProjectBadgeRoutes(app);
- registerBadgeCacheRoutes(app);
- registerIconsRoutes(app);
-}
diff --git a/src/routes/user-badge.routes.ts b/src/routes/user-badge.routes.ts
deleted file mode 100644
index 959a7f8..0000000
--- a/src/routes/user-badge.routes.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * User Badge Routes
- * Routes for user-specific badges (require username parameter)
- */
-import type { Application } from 'express';
-import { UserBadgeController } from '../controllers/user-badge.controller.js';
-
-/**
- * Register user badge routes
- */
-export function registerUserBadgeRoutes(app: Application): void {
- // Register routes
- app.get('/badge/visitors', UserBadgeController.getVisitors);
- app.get('/badge/repositories', UserBadgeController.getRepositories);
- app.get('/badge/organization', UserBadgeController.getOrganization);
- app.get('/badge/languages', UserBadgeController.getLanguages);
- app.get('/badge/followers', UserBadgeController.getFollowers);
- app.get('/badge/total-stars', UserBadgeController.getTotalStars);
- app.get('/badge/total-contributors', UserBadgeController.getTotalContributors);
- app.get('/badge/total-commits', UserBadgeController.getTotalCommits);
- app.get('/badge/total-code-reviews', UserBadgeController.getTotalCodeReviews);
- app.get('/badge/total-issues', UserBadgeController.getTotalIssues);
- app.get('/badge/total-pull-requests', UserBadgeController.getTotalPullRequests);
- app.get('/badge/total-joined-years', UserBadgeController.getTotalJoinedYears);
-}
-
-/**
- * Get route documentation for user badges
- */
-export function getUserBadgeRouteDocs(): Record {
- return {
- 'GET /badge/visitors': UserBadgeController.routeDocs.visitors,
- 'GET /badge/repositories': UserBadgeController.routeDocs.repositories,
- 'GET /badge/organization': UserBadgeController.routeDocs.organization,
- 'GET /badge/languages': UserBadgeController.routeDocs.languages,
- 'GET /badge/followers': UserBadgeController.routeDocs.followers,
- 'GET /badge/total-stars': UserBadgeController.routeDocs['total-stars'],
- 'GET /badge/total-contributors': UserBadgeController.routeDocs['total-contributors'],
- 'GET /badge/total-commits': UserBadgeController.routeDocs['total-commits'],
- 'GET /badge/total-code-reviews': UserBadgeController.routeDocs['total-code-reviews'],
- 'GET /badge/total-issues': UserBadgeController.routeDocs['total-issues'],
- 'GET /badge/total-pull-requests': UserBadgeController.routeDocs['total-pull-requests'],
- 'GET /badge/total-joined-years': UserBadgeController.routeDocs['total-joined-years'],
- };
-}
diff --git a/src/server-cluster.ts b/src/server-cluster.ts
index e259838..18efb25 100644
--- a/src/server-cluster.ts
+++ b/src/server-cluster.ts
@@ -14,7 +14,7 @@ import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-const workerFile = pathToFileURL(path.join(__dirname, 'index.js')).href;
+const workerFile = pathToFileURL(path.join(__dirname, 'server.js')).href;
const workers = parseInt(process.env.WORKERS || '0') || undefined;
startCluster(workerFile, {
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000..f483e41
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,237 @@
+/**
+ * Server Startup (Modular Architecture)
+ * Handles server initialization with modular structure
+ */
+
+import { type Server as HttpServer } from 'http';
+import { type Express } from 'express';
+import { createApp, initializeRoutes, setupErrorHandlers } from './app.js';
+import { getEnv } from './shared/config/env.js';
+import { createLogger } from './shared/logs/logger.js';
+import { initializeDatabaseAsync } from './shared/config/db.js';
+import { GitHubClient } from './shared/utils/github-client.js';
+import { closeRedisClient, getRedisClient } from './shared/utils/redis-client.js';
+import { createResponseCache } from './shared/utils/response-cache.js';
+import { scheduleStatsCleanup } from './shared/utils/stats-cleanup.js';
+import { getBadgeCacheService, getBadgeCacheServiceSync } from './services/badge-cache.service.js';
+import type { ICacheService } from './services/base.service.js';
+
+const logger = createLogger({ module: 'server' });
+let activeApp: Express | null = null;
+let activeServer: HttpServer | null = null;
+let shutdownPromise: Promise | null = null;
+let stopStatsCleanup: (() => void) | null = null;
+
+// Shared bounded cache for API responses. Capacity + TTL configured in
+// `createResponseCache`; TTL matches env.CACHE_DURATION set below.
+const cache = createResponseCache(getEnv().CACHE_DURATION);
+
+function createRedisHealthCacheService(): ICacheService {
+ return {
+ async get(key: string): Promise {
+ const client = await getRedisClient();
+ const value = await client.get(key);
+
+ if (value === null) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return value as T;
+ }
+ },
+ async set(key: string, value: T, ttl?: number): Promise {
+ const client = await getRedisClient();
+ const serializedValue = typeof value === 'string' ? value : JSON.stringify(value);
+
+ if (ttl && ttl > 0) {
+ await client.setEx(key, ttl, serializedValue);
+ return;
+ }
+
+ await client.set(key, serializedValue);
+ },
+ async del(key: string): Promise {
+ const client = await getRedisClient();
+ await client.del(key);
+ },
+ async exists(key: string): Promise {
+ const client = await getRedisClient();
+ return (await client.exists(key)) > 0;
+ },
+ async flush(): Promise {
+ const client = await getRedisClient();
+ await client.flushDb();
+ },
+ };
+}
+
+/**
+ * Initialize external services
+ */
+async function initializeServices(): Promise<{ cacheService?: ICacheService }> {
+ const env = getEnv();
+
+ // Initialize Database
+ try {
+ await initializeDatabaseAsync();
+ logger.info('Database initialized', {
+ provider: env.DATABASE_PROVIDER,
+ });
+ } catch (error) {
+ logger.error('Database initialization failed', error as Error, {
+ provider: env.DATABASE_PROVIDER,
+ });
+ throw error;
+ }
+
+ // Initialize Redis (optional)
+ let cacheService: ICacheService | undefined;
+ try {
+ await getRedisClient();
+ cacheService = createRedisHealthCacheService();
+ logger.info('Redis cache initialized');
+ } catch (error) {
+ logger.warn('Redis not available - using in-memory cache');
+ }
+
+ // Initialize badge cache singleton so per-request writers (setUserBadgeSVG /
+ // setProjectBadgeSVG) actually reach Redis. Without this call the sync
+ // accessor stays null and every badge lookup falls through to the DB.
+ try {
+ const badgeCache = await getBadgeCacheService();
+ if (badgeCache.isReady()) {
+ logger.info('Badge cache initialized');
+ }
+ } catch (error) {
+ logger.warn('Badge cache not available', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ return { cacheService };
+}
+
+/**
+ * Start the server
+ */
+export async function startServer(): Promise {
+ if (activeApp && activeServer?.listening) {
+ return activeApp;
+ }
+
+ const env = getEnv();
+
+ // Initialize services
+ const { cacheService } = await initializeServices();
+
+ // Create GitHub client
+ const githubClient = new GitHubClient(env.GITHUB_TOKEN);
+
+ // Create Express app
+ const app = createApp();
+
+ // Initialize routes with dependencies
+ initializeRoutes(app, githubClient, cache, env.CACHE_DURATION, cacheService);
+
+ // Setup error handlers
+ setupErrorHandlers(app);
+
+ // Start listening
+ const port = env.PORT;
+ const host = env.HOST;
+ const server = app.listen(port, host, () => {
+ logger.info(`Server started on port ${port}`, {
+ port,
+ host,
+ environment: env.APP_ENV,
+ nodeEnv: process.env.NODE_ENV
+ });
+ });
+
+ activeApp = app;
+ activeServer = server;
+
+ // Schedule background prune of stats_requests. `.unref()` inside so we
+ // don't block shutdown; explicit stop on stopServer() keeps tests clean.
+ if (!stopStatsCleanup) {
+ stopStatsCleanup = scheduleStatsCleanup({
+ retentionDays: env.STATS_REQUESTS_RETENTION_DAYS,
+ intervalHours: env.STATS_REQUESTS_CLEANUP_INTERVAL_HOURS,
+ });
+ }
+
+ server.on('error', (error: NodeJS.ErrnoException) => {
+ logger.error('HTTP server failed to listen', error, {
+ port,
+ host,
+ code: error.code,
+ });
+ process.exit(1);
+ });
+
+ return app;
+}
+
+export async function stopServer(): Promise {
+ if (shutdownPromise) {
+ return shutdownPromise;
+ }
+
+ shutdownPromise = (async () => {
+ if (stopStatsCleanup) {
+ stopStatsCleanup();
+ stopStatsCleanup = null;
+ }
+
+ if (activeServer) {
+ await new Promise((resolve, reject) => {
+ activeServer?.close((error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ resolve();
+ });
+ });
+
+ logger.info('HTTP server stopped');
+ }
+
+ try {
+ await getBadgeCacheServiceSync()?.disconnect();
+ } catch (error) {
+ logger.warn('Failed to close badge cache cleanly', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ try {
+ await closeRedisClient();
+ } catch (error) {
+ logger.warn('Failed to close Redis client cleanly', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+
+ activeServer = null;
+ activeApp = null;
+ })();
+
+ try {
+ await shutdownPromise;
+ } finally {
+ shutdownPromise = null;
+ }
+}
+
+// Start server if this file is run directly
+if (import.meta.url === `file://${process.argv[1]}`) {
+ startServer().catch((error) => {
+ logger.error('Failed to start server', error as Error);
+ process.exit(1);
+ });
+}
diff --git a/src/services/badge-cache.service.ts b/src/services/badge-cache.service.ts
index 6fcc2ae..d4aaf7d 100644
--- a/src/services/badge-cache.service.ts
+++ b/src/services/badge-cache.service.ts
@@ -5,8 +5,8 @@
*/
import { createClient, RedisClientType } from 'redis';
-import { createLogger, Logger } from '../common/logger.js';
-import { getConfig } from '../config/index.js';
+import { createLogger, Logger } from '../shared/logs/logger.js';
+import { getConfig } from '../shared/config/index.js';
export interface CachedBadge {
svg: string;
diff --git a/src/services/base.ts b/src/services/base.service.ts
similarity index 95%
rename from src/services/base.ts
rename to src/services/base.service.ts
index 54aa34e..e61d4b6 100644
--- a/src/services/base.ts
+++ b/src/services/base.service.ts
@@ -1,261 +1,261 @@
-/**
- * Service Layer Base Classes and Interfaces
- * Provides abstractions for business logic separation
- */
-
-import { Logger, createLogger } from '../common/logger.js';
-
-/**
- * Base service class providing common functionality
- */
-export abstract class BaseService {
- protected logger: Logger;
-
- constructor(serviceName: string) {
- this.logger = createLogger({ service: serviceName });
- }
-
- /**
- * Execute operation with error handling and logging
- */
- protected async executeWithLogging(
- operation: string,
- fn: () => Promise
- ): Promise {
- const startTime = Date.now();
- this.logger.debug(`Starting ${operation}`);
-
- try {
- const result = await fn();
- const duration = Date.now() - startTime;
- this.logger.debug(`Completed ${operation}`, { duration });
- return result;
- } catch (error) {
- const duration = Date.now() - startTime;
- this.logger.error(`Failed ${operation}`, error as Error, { duration });
- throw error;
- }
- }
-}
-
-/**
- * Cache interface for dependency injection
- */
-export interface ICacheService {
- get(key: string): Promise;
- set(key: string, value: T, ttl?: number): Promise;
- del(key: string): Promise;
- exists(key: string): Promise;
- flush(): Promise;
-}
-
-/**
- * Memory cache implementation
- */
-export class MemoryCacheService implements ICacheService {
- private cache: Map;
- private logger: Logger;
-
- constructor() {
- this.cache = new Map();
- this.logger = createLogger({ service: 'MemoryCache' });
-
- // Cleanup expired entries every 5 minutes
- setInterval(() => this.cleanup(), 5 * 60 * 1000);
- }
-
- async get(key: string): Promise {
- const entry = this.cache.get(key);
-
- if (!entry) {
- return null;
- }
-
- // Check if expired
- if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
- this.cache.delete(key);
- this.logger.debug('Cache entry expired', { key });
- return null;
- }
-
- this.logger.debug('Cache hit', { key });
- return entry.data;
- }
-
- async set(key: string, value: T, ttl?: number): Promise {
- this.cache.set(key, {
- data: value,
- timestamp: Date.now(),
- ttl,
- });
- this.logger.debug('Cache set', { key, hasTtl: !!ttl });
- }
-
- async del(key: string): Promise {
- const deleted = this.cache.delete(key);
- if (deleted) {
- this.logger.debug('Cache deleted', { key });
- }
- }
-
- async exists(key: string): Promise {
- return this.cache.has(key);
- }
-
- async flush(): Promise {
- this.cache.clear();
- this.logger.info('Cache flushed');
- }
-
- /**
- * Cleanup expired entries
- */
- private cleanup(): void {
- let removed = 0;
- const now = Date.now();
-
- for (const [key, entry] of this.cache.entries()) {
- if (entry.ttl && now - entry.timestamp > entry.ttl) {
- this.cache.delete(key);
- removed++;
- }
- }
-
- if (removed > 0) {
- this.logger.debug('Cleaned up expired entries', { count: removed });
- }
- }
-
- /**
- * Get cache statistics
- */
- getStats() {
- return {
- size: this.cache.size,
- entries: Array.from(this.cache.keys()),
- };
- }
-}
-
-/**
- * Request deduplication service
- * Prevents duplicate concurrent requests for the same resource
- */
-export class RequestDeduplicationService {
- private pendingRequests: Map>;
- private logger: Logger;
-
- constructor() {
- this.pendingRequests = new Map();
- this.logger = createLogger({ service: 'RequestDeduplication' });
- }
-
- /**
- * Execute a request with deduplication
- * If the same key is already being processed, wait for that result instead
- */
- async deduplicate(key: string, fn: () => Promise): Promise {
- // Check if request is already in flight
- const existing = this.pendingRequests.get(key);
- if (existing) {
- this.logger.debug('Request deduplicated', { key });
- return existing;
- }
-
- // Execute new request
- const promise = fn();
- this.pendingRequests.set(key, promise);
-
- try {
- const result = await promise;
- return result;
- } finally {
- this.pendingRequests.delete(key);
- }
- }
-
- /**
- * Check if a request is currently pending
- */
- isPending(key: string): boolean {
- return this.pendingRequests.has(key);
- }
-
- /**
- * Get count of pending requests
- */
- getPendingCount(): number {
- return this.pendingRequests.size;
- }
-
- /**
- * Clear all pending requests (useful for shutdown)
- */
- clear(): void {
- this.pendingRequests.clear();
- this.logger.debug('Cleared all pending requests');
- }
-}
-
-/**
- * Service container for dependency injection
- */
-export class ServiceContainer {
- private services: Map;
-
- constructor() {
- this.services = new Map();
- }
-
- /**
- * Register a service
- */
- register(key: string, service: T): void {
- this.services.set(key, service);
- }
-
- /**
- * Get a service
- */
- get(key: string): T {
- const service = this.services.get(key);
- if (!service) {
- throw new Error(`Service '${key}' not found in container`);
- }
- return service;
- }
-
- /**
- * Check if service exists
- */
- has(key: string): boolean {
- return this.services.has(key);
- }
-
- /**
- * Remove a service
- */
- remove(key: string): void {
- this.services.delete(key);
- }
-
- /**
- * Clear all services
- */
- clear(): void {
- this.services.clear();
- }
-}
-
-// Global service container
-let containerInstance: ServiceContainer | null = null;
-
-/**
- * Get global service container
- */
-export function getServiceContainer(): ServiceContainer {
- if (!containerInstance) {
- containerInstance = new ServiceContainer();
- }
- return containerInstance;
-}
+/**
+ * Service Layer Base Classes and Interfaces
+ * Provides abstractions for business logic separation
+ */
+
+import { Logger, createLogger } from '../shared/logs/logger.js';
+
+/**
+ * Base service class providing common functionality
+ */
+export abstract class BaseService {
+ protected logger: Logger;
+
+ constructor(serviceName: string) {
+ this.logger = createLogger({ service: serviceName });
+ }
+
+ /**
+ * Execute operation with error handling and logging
+ */
+ protected async executeWithLogging(
+ operation: string,
+ fn: () => Promise
+ ): Promise {
+ const startTime = Date.now();
+ this.logger.debug(`Starting ${operation}`);
+
+ try {
+ const result = await fn();
+ const duration = Date.now() - startTime;
+ this.logger.debug(`Completed ${operation}`, { duration });
+ return result;
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ this.logger.error(`Failed ${operation}`, error as Error, { duration });
+ throw error;
+ }
+ }
+}
+
+/**
+ * Cache interface for dependency injection
+ */
+export interface ICacheService {
+ get(key: string): Promise;
+ set(key: string, value: T, ttl?: number): Promise;
+ del(key: string): Promise;
+ exists(key: string): Promise;
+ flush(): Promise;
+}
+
+/**
+ * Memory cache implementation
+ */
+export class MemoryCacheService implements ICacheService {
+ private cache: Map;
+ private logger: Logger;
+
+ constructor() {
+ this.cache = new Map();
+ this.logger = createLogger({ service: 'MemoryCache' });
+
+ // Cleanup expired entries every 5 minutes
+ setInterval(() => this.cleanup(), 5 * 60 * 1000);
+ }
+
+ async get(key: string): Promise {
+ const entry = this.cache.get(key);
+
+ if (!entry) {
+ return null;
+ }
+
+ // Check if expired
+ if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
+ this.cache.delete(key);
+ this.logger.debug('Cache entry expired', { key });
+ return null;
+ }
+
+ this.logger.debug('Cache hit', { key });
+ return entry.data;
+ }
+
+ async set(key: string, value: T, ttl?: number): Promise {
+ this.cache.set(key, {
+ data: value,
+ timestamp: Date.now(),
+ ttl,
+ });
+ this.logger.debug('Cache set', { key, hasTtl: !!ttl });
+ }
+
+ async del(key: string): Promise {
+ const deleted = this.cache.delete(key);
+ if (deleted) {
+ this.logger.debug('Cache deleted', { key });
+ }
+ }
+
+ async exists(key: string): Promise {
+ return this.cache.has(key);
+ }
+
+ async flush(): Promise {
+ this.cache.clear();
+ this.logger.info('Cache flushed');
+ }
+
+ /**
+ * Cleanup expired entries
+ */
+ private cleanup(): void {
+ let removed = 0;
+ const now = Date.now();
+
+ for (const [key, entry] of this.cache.entries()) {
+ if (entry.ttl && now - entry.timestamp > entry.ttl) {
+ this.cache.delete(key);
+ removed++;
+ }
+ }
+
+ if (removed > 0) {
+ this.logger.debug('Cleaned up expired entries', { count: removed });
+ }
+ }
+
+ /**
+ * Get cache statistics
+ */
+ getStats() {
+ return {
+ size: this.cache.size,
+ entries: Array.from(this.cache.keys()),
+ };
+ }
+}
+
+/**
+ * Request deduplication service
+ * Prevents duplicate concurrent requests for the same resource
+ */
+export class RequestDeduplicationService {
+ private pendingRequests: Map>;
+ private logger: Logger;
+
+ constructor() {
+ this.pendingRequests = new Map();
+ this.logger = createLogger({ service: 'RequestDeduplication' });
+ }
+
+ /**
+ * Execute a request with deduplication
+ * If the same key is already being processed, wait for that result instead
+ */
+ async deduplicate(key: string, fn: () => Promise): Promise {
+ // Check if request is already in flight
+ const existing = this.pendingRequests.get(key);
+ if (existing) {
+ this.logger.debug('Request deduplicated', { key });
+ return existing;
+ }
+
+ // Execute new request
+ const promise = fn();
+ this.pendingRequests.set(key, promise);
+
+ try {
+ const result = await promise;
+ return result;
+ } finally {
+ this.pendingRequests.delete(key);
+ }
+ }
+
+ /**
+ * Check if a request is currently pending
+ */
+ isPending(key: string): boolean {
+ return this.pendingRequests.has(key);
+ }
+
+ /**
+ * Get count of pending requests
+ */
+ getPendingCount(): number {
+ return this.pendingRequests.size;
+ }
+
+ /**
+ * Clear all pending requests (useful for shutdown)
+ */
+ clear(): void {
+ this.pendingRequests.clear();
+ this.logger.debug('Cleared all pending requests');
+ }
+}
+
+/**
+ * Service container for dependency injection
+ */
+export class ServiceContainer {
+ private services: Map;
+
+ constructor() {
+ this.services = new Map();
+ }
+
+ /**
+ * Register a service
+ */
+ register(key: string, service: T): void {
+ this.services.set(key, service);
+ }
+
+ /**
+ * Get a service
+ */
+ get(key: string): T {
+ const service = this.services.get(key);
+ if (!service) {
+ throw new Error(`Service '${key}' not found in container`);
+ }
+ return service;
+ }
+
+ /**
+ * Check if service exists
+ */
+ has(key: string): boolean {
+ return this.services.has(key);
+ }
+
+ /**
+ * Remove a service
+ */
+ remove(key: string): void {
+ this.services.delete(key);
+ }
+
+ /**
+ * Clear all services
+ */
+ clear(): void {
+ this.services.clear();
+ }
+}
+
+// Global service container
+let containerInstance: ServiceContainer | null = null;
+
+/**
+ * Get global service container
+ */
+export function getServiceContainer(): ServiceContainer {
+ if (!containerInstance) {
+ containerInstance = new ServiceContainer();
+ }
+ return containerInstance;
+}
diff --git a/src/services/cache.service.ts b/src/services/cache.service.ts
deleted file mode 100644
index 3b7a118..0000000
--- a/src/services/cache.service.ts
+++ /dev/null
@@ -1,391 +0,0 @@
-/**
- * Redis Cache Service Implementation
- * Provides a Redis-backed cache service with fallback to memory cache
- */
-
-import { createClient, RedisClientType } from 'redis';
-import { ICacheService } from './base.js';
-import { CacheError } from '../common/errors.js';
-import { createLogger, Logger } from '../common/logger.js';
-import { getConfig } from '../config/index.js';
-
-/**
- * Redis cache service with connection pooling and error handling
- */
-export class RedisCacheService implements ICacheService {
- private client: RedisClientType | null = null;
- private logger: Logger;
- private config: ReturnType['redis'];
- private isConnected: boolean = false;
- private connectionPromise: Promise | null = null;
-
- constructor() {
- this.logger = createLogger({ service: 'RedisCache' });
- this.config = getConfig().redis;
- }
-
- /**
- * Initialize Redis connection
- */
- async connect(): Promise {
- if (this.isConnected) {
- return;
- }
-
- if (this.connectionPromise) {
- return this.connectionPromise;
- }
-
- if (!this.config.enabled) {
- this.logger.info('Redis is disabled in configuration');
- throw new CacheError('Redis is disabled');
- }
-
- this.connectionPromise = this._connect();
-
- try {
- await this.connectionPromise;
- } finally {
- this.connectionPromise = null;
- }
- }
-
- private async _connect(): Promise {
- try {
- const clientOptions = this.buildClientOptions();
-
- this.client = createClient(clientOptions);
-
- // Error handlers
- this.client.on('error', (error) => {
- this.logger.error('Redis client error', error);
- this.isConnected = false;
- });
-
- this.client.on('connect', () => {
- this.logger.info('Redis client connecting...');
- });
-
- this.client.on('ready', () => {
- this.logger.info('Redis client ready', {
- host: this.config.host || 'unknown',
- port: this.config.port,
- tls: this.config.tls,
- });
- this.isConnected = true;
- });
-
- this.client.on('reconnecting', () => {
- this.logger.warn('Redis client reconnecting...');
- this.isConnected = false;
- });
-
- this.client.on('end', () => {
- this.logger.info('Redis client disconnected');
- this.isConnected = false;
- });
-
- await this.client.connect();
- } catch (error) {
- this.logger.error('Failed to connect to Redis', error as Error);
- this.client = null;
- this.isConnected = false;
- throw new CacheError('Failed to connect to Redis', { error: (error as Error).message });
- }
- }
-
- /**
- * Build Redis client options from config
- */
- private buildClientOptions(): any {
- const { url, host, port, username, password, db, tls, connectionTimeout, commandTimeout } = this.config;
-
- // URL-based configuration
- if (url) {
- return {
- url,
- socket: {
- connectTimeout: connectionTimeout,
- commandTimeout,
- tls: tls ? {} : undefined,
- },
- };
- }
-
- // Socket-based configuration
- const shouldEnableTLS = tls ?? this.shouldEnableTLS(host);
-
- return {
- socket: {
- host: host || 'localhost',
- port: port || 6379,
- connectTimeout: connectionTimeout,
- commandTimeout,
- tls: shouldEnableTLS ? {} : undefined,
- },
- username: username || 'default',
- password: password || '',
- database: db,
- };
- }
-
- /**
- * Auto-detect TLS requirement based on host
- */
- private shouldEnableTLS(host: string | undefined): boolean {
- if (!host) return false;
-
- const tlsHosts = [
- 'cloud.redislabs.com',
- 'cache.amazonaws.com',
- 'redis-enterprise.com',
- 'redislabs.com',
- 'render.com',
- ];
-
- return tlsHosts.some(tlsHost => host.includes(tlsHost));
- }
-
- /**
- * Ensure client is connected before operation
- */
- private async ensureConnected(): Promise {
- if (!this.isConnected || !this.client) {
- await this.connect();
- }
- }
-
- /**
- * Get value from cache
- */
- async get(key: string): Promise {
- try {
- await this.ensureConnected();
-
- if (!this.client) {
- return null;
- }
-
- const value = await this.client.get(key);
-
- if (!value) {
- this.logger.debug('Cache miss', { key });
- return null;
- }
-
- this.logger.debug('Cache hit', { key });
- return JSON.parse(value) as T;
- } catch (error) {
- this.logger.error('Cache get error', error as Error, { key });
- return null; // Fail gracefully
- }
- }
-
- /**
- * Set value in cache
- */
- async set(key: string, value: T, ttl?: number): Promise {
- try {
- await this.ensureConnected();
-
- if (!this.client) {
- return;
- }
-
- const serialized = JSON.stringify(value);
-
- if (ttl && ttl > 0) {
- await this.client.setEx(key, Math.floor(ttl / 1000), serialized);
- } else {
- await this.client.set(key, serialized);
- }
-
- this.logger.debug('Cache set', { key, hasTtl: !!ttl });
- } catch (error) {
- this.logger.error('Cache set error', error as Error, { key });
- // Fail gracefully - don't throw on cache errors
- }
- }
-
- /**
- * Delete value from cache
- */
- async del(key: string): Promise {
- try {
- await this.ensureConnected();
-
- if (!this.client) {
- return;
- }
-
- await this.client.del(key);
- this.logger.debug('Cache deleted', { key });
- } catch (error) {
- this.logger.error('Cache delete error', error as Error, { key });
- }
- }
-
- /**
- * Check if key exists
- */
- async exists(key: string): Promise {
- try {
- await this.ensureConnected();
-
- if (!this.client) {
- return false;
- }
-
- const result = await this.client.exists(key);
- return result === 1;
- } catch (error) {
- this.logger.error('Cache exists error', error as Error, { key });
- return false;
- }
- }
-
- /**
- * Flush all cache entries
- */
- async flush(): Promise