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 @@ - +
-![Portfolio](https://stats.pphat.top/badge/visitors?username=pphatdev&theme=ocean) -![Portfolio](https://stats.pphat.top/badge/repositories?username=pphatdev&theme=ocean) -![Portfolio](https://stats.pphat.top/badge/languages?username=pphatdev&theme=ocean) -![Portfolio](https://stats.pphat.top/badge/followers?username=pphatdev&theme=ocean) -![Portfolio](https://stats.pphat.top/badge/total-stars?username=pphatdev&theme=ocean) -![Portfolio](https://stats.pphat.top/badge/total-contributors?username=pphatdev&theme=ocean) +![](https://stats.pphat.top/badges?username=pphatdev&repo=github-stats&name=visitors,forks,contributors,pull-requests,watchers,size&theme=ocean,neon,inferno,matrix,solar,galaxy&padding=10) +
# 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 ``` -![badge-visitors](https://stats.pphat.top/badge/visitors?username=pphatdev) -![badge-repositories](https://stats.pphat.top/badge/repositories?username=pphatdev) -![badge-followers](https://stats.pphat.top/badge/followers?username=pphatdev) -![badge-total-stars](https://stats.pphat.top/badge/total-stars?username=pphatdev) +/badges?username={username}&repo={repo}&name={badge1,badge2,...}&theme={theme}&effect={wave|glow}&column={1-50}&size={small|medium|large}&p={0-100} ``` -![badge-visitors](https://stats.pphat.top/badge/visitors?username=pphatdev) -![badge-repositories](https://stats.pphat.top/badge/repositories?username=pphatdev) -![badge-followers](https://stats.pphat.top/badge/followers?username=pphatdev) -![badge-total-stars](https://stats.pphat.top/badge/total-stars?username=pphatdev) +### 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 ``` -![badge-custom-label](https://stats.pphat.top/badge/repositories?username=pphatdev&theme=ocean&customLabel=Public%20Repos) +![badge-visitors](https://stats.pphat.top/badges?username=pphatdev&name=visitors) ``` -![badge-custom-label](https://stats.pphat.top/badge/repositories?username=pphatdev&theme=ocean&customLabel=Public%20Repos) +![badge-visitors](https://stats.pphat.top/badges?username=pphatdev&name=visitors) +![badge-repositories](https://stats.pphat.top/badges?username=pphatdev&name=repositories) +![badge-followers](https://stats.pphat.top/badges?username=pphatdev&name=followers) +![badge-total-stars](https://stats.pphat.top/badges?username=pphatdev&name=total-stars) -### Color and Layout Controls +### Multiple Badges with Theme ``` -![badge-colors](https://stats.pphat.top/badge/total-issues?username=pphatdev&labelBackground=0d1117&labelColor=ffffff&valueBackground=1f2937&valueColor=22c55e) -![badge-minimal](https://stats.pphat.top/badge/total-pull-requests?username=pphatdev&theme=tokyonight&hideFrame=true&hideIcon=true) +![badge-collection](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,followers&theme=ocean&column=3&size=medium) ``` -![badge-colors](https://stats.pphat.top/badge/total-issues?username=pphatdev&labelBackground=0d1117&labelColor=ffffff&valueBackground=1f2937&valueColor=22c55e) -![badge-minimal](https://stats.pphat.top/badge/total-pull-requests?username=pphatdev&theme=tokyonight&hideFrame=true&hideIcon=true) +![badge-collection](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,followers&theme=ocean&column=3&size=medium) +### Multiple Themes (cycled per badge) -# ๐Ÿ“ Project Badge Examples +``` +![badge-multi-theme](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories,total-issues,followers&theme=galaxy,aurora,ocean) +``` -for more detail checkout [Here](docs/example/project.md) +![badge-multi-theme](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories,total-issues,followers&theme=galaxy,aurora,ocean) -### Popular Project Badge Types +### Repository Badges ``` -![project-visitors](https://stats.pphat.top/project/visitors?repo=pphatdev/github-stats) -![project-stars](https://stats.pphat.top/project/stars?repo=pphatdev/github-stats) -![project-forks](https://stats.pphat.top/project/forks?repo=pphatdev/github-stats) -![project-watchers](https://stats.pphat.top/project/watchers?repo=pphatdev/github-stats) +![repo-badges](https://stats.pphat.top/badges?username=pphatdev&repo=github-stats&name=stars,forks,contributors&theme=galaxy&effect=wave) ``` -![project-visitors](https://stats.pphat.top/project/visitors?repo=pphatdev/github-stats) -![project-stars](https://stats.pphat.top/project/stars?repo=pphatdev/github-stats) -![project-forks](https://stats.pphat.top/project/forks?repo=pphatdev/github-stats) -![project-watchers](https://stats.pphat.top/project/watchers?repo=pphatdev/github-stats) +![repo-badges](https://stats.pphat.top/badges?username=pphatdev&repo=github-stats&name=stars,forks,contributors&theme=galaxy&effect=wave) -### Theme + Custom Label +### Effects (`glow` | `wave`) ``` -![project-custom](https://stats.pphat.top/project/contributors?repo=pphatdev/github-stats&theme=ocean&customLabel=Contributors) +![badge-glow](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories,total-issues&effect=glow) +![badge-wave](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories,total-issues&effect=wave) ``` -![project-custom](https://stats.pphat.top/project/contributors?repo=pphatdev/github-stats&theme=ocean&customLabel=Contributors) +![badge-glow](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories,total-issues&effect=glow) +![badge-wave](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories,total-issues&effect=wave) + +### Combined Example (layout + themes + effect + padding) + +``` +![badge-combined](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories,total-issues,followers,total-pull-requests&column=3&theme=galaxy,aurora,ocean&effect=wave&size=large&p=15) +``` -### Visitors Rule (Same IP) +![badge-combined](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories,total-issues,followers,total-pull-requests&column=3&theme=galaxy,aurora,ocean&effect=wave&size=large&p=15) -Project visitors increment once per same IP every 5 minutes. +### Fresh Data with Realtime ``` -![project-visitors](https://stats.pphat.top/project/visitors?repo=pphatdev/github-stats) +![badge-realtime](https://stats.pphat.top/badges?username=pphatdev&name=total-stars&realtime=true) ``` +![badge-realtime](https://stats.pphat.top/badges?username=pphatdev&name=total-stars&realtime=true) -## 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 ![Docker](https://stats.pphat.top/icons/docker?color=%232496ED&glow=true&glowColor=%232496ED) ![GitHub Actions](https://stats.pphat.top/icons/githubactions?color=%232088FF&glow=true&glowColor=%232088FF) -![Vercel](https://stats.pphat.top/icons/vercel?color=white&glow=true&glowColor=white) ``` ### 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 + +![TypeScript](https://stats.pphat.top/icons/typescript?color=%233178C6) ![Node.js](https://stats.pphat.top/icons/nodedotjs?color=%23339933) ![GitHub](https://stats.pphat.top/icons/github?color=white) + +## 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 +![badge-collection](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories&columns=3) +``` + +### Release Badge Examples (v2.0.1) + +```markdown +![version](https://img.shields.io/badge/version-2.0.1-22c55e) +![badge-collection-demo](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,followers&columns=2&gap=10) +``` + +Preview: + +![badge-collection-demo](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,followers&columns=2&gap=10) + +--- + ## 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: + +![badge-collection-basic](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars) + +Multiple badges with 3 columns: + +![badge-collection-columns](https://stats.pphat.top/badges?username=pphatdev&name=visitors,repositories,followers,total-stars,total-issues,total-pull-requests&column=3) + +## Layout Examples + +Custom columns: + +![badge-collection-layout](https://stats.pphat.top/badges?username=pphatdev&name=visitors,repositories,followers,total-stars,total-issues,total-pull-requests&column=2) + +Single-row layout: + +![badge-collection-row](https://stats.pphat.top/badges?username=pphatdev&name=visitors,repositories,followers,total-stars,total-issues,total-pull-requests&column=6) + +Large size preset: + +![badge-collection-large](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories&size=large) + +## Style Examples + +Theme cycling: + +![badge-collection-theme](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories&theme=ocean,aurora) + +Glow effect: + +![badge-collection-glow](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories&effect=glow) + +Wave effect: + +![badge-collection-wave](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories&effect=wave) + +Custom colors: + +![badge-collection-colors](https://stats.pphat.top/badges?username=pphatdev&name=visitors,total-stars,repositories&labelBackground=0d1117&labelColor=ffffff&valueBackground=1f2937&valueColor=22c55e) + +## Mixed User + Repo Example + +![badge-collection-mixed](https://stats.pphat.top/badges?username=pphatdev&repo=github-stats&name=visitors,total-stars,stars,forks&column=2&theme=galaxy,ocean) + +## 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](https://stats.pphat.top/badge/visitors?username=pphatdev) -![repositories](https://stats.pphat.top/badge/repositories?username=pphatdev) -![organization](https://stats.pphat.top/badge/organization?username=pphatdev) -![languages](https://stats.pphat.top/badge/languages?username=pphatdev) -![followers](https://stats.pphat.top/badge/followers?username=pphatdev) -![total-stars](https://stats.pphat.top/badge/total-stars?username=pphatdev) -![total-contributors](https://stats.pphat.top/badge/total-contributors?username=pphatdev) -![total-commits](https://stats.pphat.top/badge/total-commits?username=pphatdev) -![total-code-reviews](https://stats.pphat.top/badge/total-code-reviews?username=pphatdev) -![total-issues](https://stats.pphat.top/badge/total-issues?username=pphatdev) -![total-pull-requests](https://stats.pphat.top/badge/total-pull-requests?username=pphatdev) -![total-joined-years](https://stats.pphat.top/badge/total-joined-years?username=pphatdev) +- `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 + +![visitors](https://stats.pphat.top/badges?username=pphatdev&name=visitors) +![repositories](https://stats.pphat.top/badges?username=pphatdev&name=repositories) +![followers](https://stats.pphat.top/badges?username=pphatdev&name=followers) +![total-stars](https://stats.pphat.top/badges?username=pphatdev&name=total-stars) + +## Repo Badge Examples + +![repo-stars](https://stats.pphat.top/badges?username=pphatdev&repo=github-stats&name=stars) +![repo-forks](https://stats.pphat.top/badges?username=pphatdev&repo=github-stats&name=forks) + +## Style Examples | Param | Preview | |---|---| -| `theme` | ![theme](https://stats.pphat.top/badge/visitors?username=pphatdev&theme=ocean) | -| `customLabel` | ![customLabel](https://stats.pphat.top/badge/repositories?username=pphatdev&customLabel=Public%20Repos) | -| `labelColor` | ![labelColor](https://stats.pphat.top/badge/followers?username=pphatdev&labelColor=ffffff) | -| `labelBackground` | ![labelBackground](https://stats.pphat.top/badge/languages?username=pphatdev&labelBackground=0d1117) | -| `iconColor` | ![iconColor](https://stats.pphat.top/badge/total-stars?username=pphatdev&iconColor=58a6ff) | -| `valueColor` | ![valueColor](https://stats.pphat.top/badge/total-commits?username=pphatdev&valueColor=22c55e) | -| `valueBackground` | ![valueBackground](https://stats.pphat.top/badge/total-issues?username=pphatdev&valueBackground=1f2937) | -| `hideFrame` | ![hideFrame](https://stats.pphat.top/badge/total-pull-requests?username=pphatdev&hideFrame=true) | -| `hideIcon` | ![hideIcon](https://stats.pphat.top/badge/total-joined-years?username=pphatdev&hideIcon=true) | - -## Combined Demos - -![combined-stars](https://stats.pphat.top/badge/total-stars?username=pphatdev&theme=ocean&customLabel=Total%20Stars&hideFrame=true&hideIcon=true) -![combined-followers](https://stats.pphat.top/badge/followers?username=pphatdev&theme=dracula&labelBackground=0d1117&labelColor=ffffff&iconColor=ff79c6&valueColor=f8f8f2) -![combined-repositories](https://stats.pphat.top/badge/repositories?username=pphatdev&theme=tokyonight&customLabel=My%20Projects&valueBackground=111827) +| `theme` | ![theme](https://stats.pphat.top/badges?username=pphatdev&name=visitors&theme=ocean) | +| `customLabel` | ![customLabel](https://stats.pphat.top/badges?username=pphatdev&name=repositories&customLabel=Public%20Repos) | +| `labelColor` | ![labelColor](https://stats.pphat.top/badges?username=pphatdev&name=followers&labelColor=ffffff) | +| `labelBackground` | ![labelBackground](https://stats.pphat.top/badges?username=pphatdev&name=languages&labelBackground=0d1117) | +| `iconColor` | ![iconColor](https://stats.pphat.top/badges?username=pphatdev&name=total-stars&iconColor=58a6ff) | +| `valueColor` | ![valueColor](https://stats.pphat.top/badges?username=pphatdev&name=total-commits&valueColor=22c55e) | +| `valueBackground` | ![valueBackground](https://stats.pphat.top/badges?username=pphatdev&name=total-issues&valueBackground=1f2937) | +| `hideFrame` | ![hideFrame](https://stats.pphat.top/badges?username=pphatdev&name=total-pull-requests&hideFrame=true) | +| `hideIcon` | ![hideIcon](https://stats.pphat.top/badges?username=pphatdev&name=total-joined-years&hideIcon=true) | + +## Combined Examples + +![combined-stars](https://stats.pphat.top/badges?username=pphatdev&name=total-stars&theme=ocean&customLabel=Total%20Stars&hideFrame=true&hideIcon=true) +![combined-followers](https://stats.pphat.top/badges?username=pphatdev&name=followers&theme=dracula&labelBackground=0d1117&labelColor=ffffff&iconColor=ff79c6&valueColor=f8f8f2) +![combined-repositories](https://stats.pphat.top/badges?username=pphatdev&name=repositories&theme=tokyonight&customLabel=My%20Projects&valueBackground=111827) 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 -![GitHub Stats](https://stats.pphat.top/stats?username=pphatdev&theme=tokyo) -``` - -### 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 -![Top Languages](https://stats.pphat.top/languages?username=pphatdev&theme=tokyo) -``` - -### 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 -![Contribution Graph](https://stats.pphat.top/graph?username=pphatdev&theme=tokyo&format=svg) -``` - -### 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 -![Stats](https://stats.pphat.top/stats?username=yourname&theme=tokyo) -``` - -#### LinkedIn Profile -Embed as image URL in profile - -#### Portfolio Website -```html -GitHub Stats -``` - -#### 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 -![Visitors](https://stats.pphat.top/project/visitors?repo=pphatdev/github-stats) -``` - ---- - -## 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 -![Stars](https://stats.pphat.top/project/stars?repo=pphatdev/github-stats) -``` - -### HTML Embedding -```html -Repository Stars -``` - -### 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 -[![Forks](https://stats.pphat.top/project/forks?repo=pphatdev/github-stats)](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 -![Watchers](https://stats.pphat.top/project/watchers?repo=pphatdev/github-stats) -``` - ---- - -## 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 -[![Issues](https://stats.pphat.top/project/issues?repo=pphatdev/github-stats)](https://github.com/pphatdev/github-stats/issues) -``` - -### Markdown Embedding -```markdown -![Open Issues](https://stats.pphat.top/project/issues?repo=pphatdev/github-stats&theme=tokyo) -``` - ---- - -## 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 -![Pull Requests](https://stats.pphat.top/project/prs?repo=pphatdev/github-stats&theme=tokyo) -``` - ---- - -## 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 -![Contributors](https://stats.pphat.top/project/contributors?repo=pphatdev/github-stats) -``` - -### 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 -![Size](https://stats.pphat.top/project/size?repo=pphatdev/github-stats) -``` - -### 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 - -![Stars](https://stats.pphat.top/project/stars?repo=pphatdev/github-stats&theme=tokyo) -![Forks](https://stats.pphat.top/project/forks?repo=pphatdev/github-stats&theme=tokyo) -![Issues](https://stats.pphat.top/project/issues?repo=pphatdev/github-stats&theme=tokyo) -![PRs](https://stats.pphat.top/project/prs?repo=pphatdev/github-stats&theme=tokyo) - -## About This Project - -This is an amazing project that generates GitHub statistics. - -### Statistics - -| Metric | Count | -|--------|-------| -| ![Stars](https://stats.pphat.top/project/stars?repo=pphatdev/github-stats) | Stars | -| ![Contributors](https://stats.pphat.top/project/contributors?repo=pphatdev/github-stats) | Contributors | -| ![Size](https://stats.pphat.top/project/size?repo=pphatdev/github-stats) | 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 -[![Stars](URL)](https://github.com/owner/repo) -[![Issues](URL)](https://github.com/owner/repo/issues) -[![PRs](URL)](https://github.com/owner/repo/pulls) -``` - -### 3. Group Related Badges -Organize badges logically in your README: -```markdown -## Project Stats - -![Stars](URL) ![Forks](URL) ![Contributors](URL) - -## Community - -![Issues](URL) ![PRs](URL) -``` - -### 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** - `![Stats](https://stats.pphat.top/stats?username=pphatdev)` -- **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 -![Visitors](https://stats.pphat.top/badge/visitors?username=pphatdev&theme=tokyo) -``` - -### 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 -![Public Repos](https://stats.pphat.top/badge/repositories?username=pphatdev) -``` - ---- - -## 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 -![Languages](https://stats.pphat.top/badge/languages?username=pphatdev) -``` - ---- - -## 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 -![Followers](https://stats.pphat.top/badge/followers?username=pphatdev&theme=tokyo) -``` - ---- - -## 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 -![Total Stars](https://stats.pphat.top/badge/total-stars?username=pphatdev&theme=tokyo) -``` - ---- - -## 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 -![Total Commits](https://stats.pphat.top/badge/total-commits?username=pphatdev) -``` - ---- - -## 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 -![Pull Requests](https://stats.pphat.top/badge/total-pull-requests?username=pphatdev&theme=tokyo) -``` - ---- - -## 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! ๐Ÿ‘‹ - -![Visitors](https://stats.pphat.top/badge/visitors?username=pphatdev&theme=tokyo) -![Followers](https://stats.pphat.top/badge/followers?username=pphatdev&theme=tokyo) -![Total Stars](https://stats.pphat.top/badge/total-stars?username=pphatdev&theme=tokyo) - -## My Statistics - -![GitHub Stats](https://stats.pphat.top/stats?username=pphatdev&theme=tokyo) - -## Quick Stats - -- ![Repos](https://stats.pphat.top/badge/repositories?username=pphatdev) Public Repositories -- ![Languages](https://stats.pphat.top/badge/languages?username=pphatdev) Programming Languages -- ![Commits](https://stats.pphat.top/badge/total-commits?username=pphatdev) Total Commits -- ![PRs](https://stats.pphat.top/badge/total-pull-requests?username=pphatdev) 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 @@ - - - - + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 + + + + + \ 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 @@ - - , blog posts, etc.); the + * defaults would block those cross-origin loads at the browser. + */ +export const securityMiddleware = helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + crossOriginResourcePolicy: { policy: 'cross-origin' }, +}); + +/** + * Rate limiting to prevent abuse + */ +export const rateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 1000, // Limit each IP to 1000 requests per windowMs + standardHeaders: true, // Return rate limit info in headers + legacyHeaders: false, + message: 'Too many requests from this IP, please try again later.', + skip: (req: Request) => { + // Skip rate limiting for health checks + return req.path.startsWith('/health'); + } +}); + +/** + * Aggressive rate limiter for expensive operations + */ +export const strictRateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 30, // Limit to 30 requests per minute + message: 'Rate limit exceeded for this endpoint.', +}); + +/** + * Add ETag and cache control headers + */ +export const cacheControlMiddleware = (maxAge: number = 600) => { + return (req: Request, res: Response, next: NextFunction) => { + // Set cache headers + res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${maxAge * 2}, stale-while-revalidate=${maxAge * 4}`); + res.setHeader('Vary', 'Accept-Encoding, Origin'); + + next(); + }; +}; + +/** + * Response time tracking + */ +export const responseTimeMiddleware = (req: Request, res: Response, next: NextFunction) => { + const startTime = Date.now(); + + // Track when response is finished + res.on('finish', () => { + const duration = Date.now() - startTime; + res.setHeader('X-Response-Time', `${duration}ms`); + + // Log slow requests + if (duration > 1000) { + logger.warn('Slow request detected', { + method: req.method, + path: req.path, + duration, + query: req.query + }); + } + }); + + next(); +}; + +/** + * Keep-Alive connection optimizer + */ +export const keepAliveMiddleware = (req: Request, res: Response, next: NextFunction) => { + res.setHeader('Connection', 'keep-alive'); + res.setHeader('Keep-Alive', 'timeout=5, max=1000'); + next(); +}; + +/** + * Preload hints for better performance + */ +export const preloadMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Add resource hints for common assets + if (req.path === '/') { + res.setHeader('Link', [ + '; rel=preload; as=document', + '; rel=preconnect', + ].join(', ')); + } + next(); +}; + +/** + * Content-Type optimization + */ +export const contentTypeOptimizer = (req: Request, res: Response, next: NextFunction) => { + const originalJson = res.json.bind(res); + + // Override json method to add charset + res.json = function (obj: any) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + return originalJson(obj); + }; + + next(); +}; + +/** + * Memory-efficient JSON stringification + */ +export const streamJsonResponse = (data: any, res: Response) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + + // For large arrays, stream the response + if (Array.isArray(data) && data.length > 100) { + res.write('['); + data.forEach((item, index) => { + res.write(JSON.stringify(item)); + if (index < data.length - 1) { + res.write(','); + } + }); + res.write(']'); + res.end(); + } else { + res.json(data); + } +}; + +/** + * Request coalescing cache + * Prevents duplicate concurrent requests to the same endpoint + */ +const pendingRequests = new Map>(); + +export const requestCoalescingMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Only coalesce GET requests + if (req.method !== 'GET') { + return next(); + } + + // Create cache key from URL and query params + const cacheKey = req.originalUrl || req.url; + + // Check if there's a pending request for the same URL + const pending = pendingRequests.get(cacheKey); + if (pending) { + logger.debug('Request coalesced', { url: cacheKey }); + + // Wait for the pending request and use its result + pending.then((cachedResponse) => { + if (cachedResponse) { + res.setHeader('X-Request-Coalesced', 'true'); + if (cachedResponse.headers) { + Object.entries(cachedResponse.headers).forEach(([key, value]) => { + res.setHeader(key, value as string); + }); + } + res.status(cachedResponse.status || 200); + if (cachedResponse.contentType?.includes('json')) { + res.json(cachedResponse.body); + } else { + res.send(cachedResponse.body); + } + } + }).catch(() => { + // If pending request failed, continue with normal processing + next(); + }); + return; + } + + // Store original res.send and res.json + const originalSend = res.send.bind(res); + const originalJson = res.json.bind(res); + let capturedResponse: any = null; + + // Create a promise for this request + const requestPromise = new Promise((resolve) => { + // Override response methods to capture the response + res.send = function (body: any) { + capturedResponse = { + status: res.statusCode, + headers: { ...res.getHeaders() }, + body, + contentType: res.getHeader('content-type') + }; + resolve(capturedResponse); + return originalSend(body); + }; + + res.json = function (obj: any) { + capturedResponse = { + status: res.statusCode, + headers: { ...res.getHeaders() }, + body: obj, + contentType: 'application/json' + }; + resolve(capturedResponse); + return originalJson(obj); + }; + }); + + // Store the promise + pendingRequests.set(cacheKey, requestPromise); + + // Clean up after response + res.on('finish', () => { + setTimeout(() => { + pendingRequests.delete(cacheKey); + }, 100); // Keep for 100ms to catch concurrent requests + }); + + next(); +}; + +/** + * All performance middlewares combined + */ +export const performanceStack = [ + responseTimeMiddleware, + keepAliveMiddleware, + compressionMiddleware, + securityMiddleware, + preloadMiddleware, + contentTypeOptimizer, + requestCoalescingMiddleware, +]; diff --git a/src/shared/middlewares/track-request.middleware.ts b/src/shared/middlewares/track-request.middleware.ts new file mode 100644 index 0000000..e18f058 --- /dev/null +++ b/src/shared/middlewares/track-request.middleware.ts @@ -0,0 +1,100 @@ +/** + * Track Request Middleware + * + * Logs every incoming card request (stats, languages, graph, badges) to the + * `stats_requests` table so admins can see who requested what โ€” including + * programmatic user-agents (python-requests, curl, bots) that previously + * collapsed into a single row via the old `url` unique index. + * + * Growth defences (H7): + * - Reject malformed usernames before touching the DB. + * - Coalesce identical requests within the same UTC hour via an LRU-capped + * Set so a hot badge doesn't produce one row per hit. The DB rows still + * represent "at least one visit in this hour bucket". + * - Rows older than env.STATS_REQUESTS_RETENTION_DAYS are pruned by a + * scheduled job in server.ts (see shared/utils/stats-cleanup.ts). + */ + +import type { Request, Response, NextFunction } from 'express'; +import { LRUCache } from 'lru-cache'; +import { db } from '../../db/index.js'; +import { statsRequests } from '../../db/schema.js'; +import { createLogger } from '../logs/logger.js'; +import { isValidGithubUsername } from '../utils/username.js'; + +const logger = createLogger({ service: 'TrackRequestMiddleware' }); + +// Bounded hourly-dedup cache. Each entry is (username|url|user_agent|hour) +// โ†’ present. Capacity 20k keeps memory in check even if a viral README +// cycles through many UAs; TTL of ~90 min accommodates clock skew and +// bucket rollover without an explicit sweep. +const HOUR_BUCKET_TTL_MS = 90 * 60 * 1000; +const seenThisHour = new LRUCache({ + max: 20_000, + ttl: HOUR_BUCKET_TTL_MS, +}); + +function normalizeEndpoint(req: Request): string { + const entries = Object.entries(req.query) + .flatMap(([key, value]) => { + if (value === undefined || value === null) return []; + if (Array.isArray(value)) { + return value.map((item) => [key, String(item)] as [string, string]); + } + return [[key, String(value)] as [string, string]]; + }) + .sort(([aKey, aVal], [bKey, bVal]) => { + const keyCompare = aKey.localeCompare(bKey); + return keyCompare !== 0 ? keyCompare : aVal.localeCompare(bVal); + }); + + const queryString = new URLSearchParams(entries).toString(); + const pathName = `${req.baseUrl}${req.path}`; + return queryString ? `${pathName}?${queryString}` : pathName; +} + +/** Floor `Date.now()` to the hour boundary (UTC). Used as part of the dedup + * cache key so hits within the same hour collapse to one write. */ +function currentHourBucket(): number { + return Math.floor(Date.now() / (60 * 60 * 1000)); +} + +export function trackRequest(req: Request, _res: Response, next: NextFunction): void { + const rawUsername = typeof req.query.username === 'string' ? req.query.username : null; + + // Reject anything that isn't a valid GitHub username โ€” an attacker could + // otherwise stuff arbitrary text into the table (bloat, log injection, + // downstream rendering hazards). + if (!rawUsername || !isValidGithubUsername(rawUsername)) { + next(); + return; + } + const username = rawUsername; + + const url = normalizeEndpoint(req); + const userAgent = req.get('user-agent') || null; + const bucket = currentHourBucket(); + const dedupKey = `${username}|${url}|${userAgent ?? ''}|${bucket}`; + + if (seenThisHour.has(dedupKey)) { + next(); + return; + } + seenThisHour.set(dedupKey, true); + + // Fire-and-forget: never block the response on the stats write. + void (async () => { + try { + await db.insert(statsRequests).values({ + username, + url, + user_agent: userAgent, + created_at: Date.now(), + }); + } catch (err) { + logger.error('Failed to log request', err as Error, { username, url }); + } + })(); + + next(); +} diff --git a/src/types/badge.types.ts b/src/shared/types/badge.types.ts similarity index 100% rename from src/types/badge.types.ts rename to src/shared/types/badge.types.ts diff --git a/src/shared/types/github.types.ts b/src/shared/types/github.types.ts new file mode 100644 index 0000000..a43c671 --- /dev/null +++ b/src/shared/types/github.types.ts @@ -0,0 +1,48 @@ +import { ThemeOverrides } from "./themes.type.js"; + +export interface GitHubStats { + name: string; + avatarUrl: string; + totalStars: number; + totalCommits: number; + totalPRs: number; + totalIssues: number; + contributedTo: number; + /** All-time contribution count from GitHub's contribution calendar + * (commits + PRs + issues + PR reviews, public + anonymized-private). + * Matches the number shown on github.com/ profile heatmap. */ + totalContributions: number; + rank?: { + level: string; + score: number; + }; +} + + +export interface ContributionGraphData { + username: string; + year: string | number; + totalContributions: number; + weeks: ContributionDay[][]; +} + +export interface ContributionDay { + date: string; + count: number; + level: number; +} + +export interface GraphCardOptions extends ThemeOverrides { + year?: string | number; + animate?: 'none' | 'glow' | 'wave' | 'pulse'; + /** Output format. Default: 'svg'. Use 'webp', 'png', or 'gif' for raster conversion. */ + as?: 'svg' | 'webp' | 'png' | 'gif'; + /** Canvas size preset. All presets now default to 512ร—256. */ + size?: 'small' | 'medium' | 'large' | 'default'; + /** Show/hide the title (username + year). When false, content is centered. Default: true */ + show_title?: boolean; + /** Show/hide the total contributions subtitle. When false, SVG height shrinks to fit content. Default: true */ + show_total_contribution?: boolean; + /** Show/hide the background (gradient, stars, grid lines). When false, bg is transparent and SVG width fits the cells. Default: true */ + show_background?: boolean; +} \ No newline at end of file diff --git a/src/shared/types/language.types.ts b/src/shared/types/language.types.ts new file mode 100644 index 0000000..80c79bc --- /dev/null +++ b/src/shared/types/language.types.ts @@ -0,0 +1,21 @@ +import { ThemeOverrides } from "./themes.type.js"; + +export interface LanguageCount { + name: string; + count: number; +} + + +export interface LanguagesCardOptions extends ThemeOverrides { + showInfo?: boolean; + listLength?: number; + variant?: 'bubbles' | 'pie'; + dataBorderStyle?: 'solid' | 'frame'; + dataBorderFramePosition?: 'in' | 'out'; + size?: 'small' | 'medium' | 'large' | 'default'; +} + +export interface LanguagesPieChartOptions extends ThemeOverrides { + listLength?: number; + size?: 'small' | 'medium' | 'large' | 'default'; +} \ No newline at end of file diff --git a/src/shared/types/themes.type.ts b/src/shared/types/themes.type.ts new file mode 100644 index 0000000..79d5052 --- /dev/null +++ b/src/shared/types/themes.type.ts @@ -0,0 +1,18 @@ +export interface ThemeOverrides { + theme?: string; + bgColor?: string; + borderColor?: string; + textColor?: string; + titleColor?: string; +} + +export interface Theme { + titleColor: string; + textColor: string; + iconColor: string; + bgColor: string; + borderColor: string; + fontName?: string; + fontFamily?: string; + fontUrl?: string; +} diff --git a/src/utils/badge-cache-manager.ts b/src/shared/utils/badge-cache-manager.ts similarity index 94% rename from src/utils/badge-cache-manager.ts rename to src/shared/utils/badge-cache-manager.ts index efaf0b6..b61e7ba 100644 --- a/src/utils/badge-cache-manager.ts +++ b/src/shared/utils/badge-cache-manager.ts @@ -1,169 +1,169 @@ -/** - * Badge Cache Invalidation & Warming Utilities - * Manages cache lifecycle for optimal data freshness and performance - */ - -import { getBadgeCacheServiceSync } from '../services/badge-cache.service.js'; -import { createLogger } from '../common/logger.js'; - -const logger = createLogger({ service: 'BadgeCacheManager' }); - -/** - * Invalidate all badges for a user when GitHub data is refreshed - * Call this when a user profile is updated from GitHub API - */ -export async function invalidateUserBadgeCache(username: string): Promise { - const badgeService = getBadgeCacheServiceSync(); - if (!badgeService?.isReady()) { - logger.debug('Badge cache not available for invalidation', { username }); - return; - } - - try { - await badgeService.invalidateUserBadges(username); - logger.info('User badge cache invalidated', { username }); - } catch (error) { - logger.error('Failed to invalidate user badge cache', error as Error, { username }); - } -} - -/** - * Invalidate all badges for a project when repo data is refreshed - * Call this when repository stats are updated from GitHub API - */ -export async function invalidateProjectBadgeCache(owner: string, repo: string): Promise { - const badgeService = getBadgeCacheServiceSync(); - if (!badgeService?.isReady()) { - logger.debug('Badge cache not available for invalidation', { owner, repo }); - return; - } - - try { - await badgeService.invalidateProjectBadges(owner, repo); - logger.info('Project badge cache invalidated', { owner, repo }); - } catch (error) { - logger.error('Failed to invalidate project badge cache', error as Error, { owner, repo }); - } -} - -/** - * Warm up cache with popular badges to reduce cold starts - * Should be triggered during application startup or maintenance windows - */ -export async function warmupBadgeCache( - usernames: string[] = [], - projects: Array<{ owner: string; repo: string }> = [], -): Promise<{ warmed: number; errors: number }> { - const badgeService = getBadgeCacheServiceSync(); - if (!badgeService?.isReady()) { - logger.warn('Badge cache not available for warmup'); - return { warmed: 0, errors: 0 }; - } - - let warmed = 0; - let errors = 0; - - logger.info('Starting badge cache warmup', { usernames: usernames.length, projects: projects.length }); - - // Warmup user badges - for (const username of usernames) { - try { - // Note: This is a placeholder. In a real scenario, you'd want to trigger - // the actual badge endpoints to populate the cache - logger.debug('User badge warmup queued', { username }); - warmed++; - } catch (error) { - logger.error('Failed to warmup user badge', error as Error, { username }); - errors++; - } - } - - // Warmup project badges - for (const { owner, repo } of projects) { - try { - logger.debug('Project badge warmup queued', { owner, repo }); - warmed++; - } catch (error) { - logger.error('Failed to warmup project badge', error as Error, { owner, repo }); - errors++; - } - } - - logger.info('Badge cache warmup completed', { warmed, errors }); - return { warmed, errors }; -} - -/** - * Get cache statistics and health info - */ -export async function getCacheStats(): Promise<{ - connected: boolean; - dbSize?: number; - memory?: string; - health: 'healthy' | 'degraded' | 'offline'; -}> { - const badgeService = getBadgeCacheServiceSync(); - if (!badgeService) { - return { - connected: false, - health: 'offline', - }; - } - - try { - const stats = await badgeService.getStats(); - if (!stats) { - return { - connected: false, - health: 'offline', - }; - } - - return { - connected: stats.connected, - dbSize: stats.dbSize, - memory: stats.memory, - health: stats.connected ? 'healthy' : 'offline', - }; - } catch (error) { - logger.error('Failed to get cache stats', error as Error); - return { - connected: false, - health: 'offline', - }; - } -} - -/** - * Implement TTL-based cache invalidation strategy - * Smaller TTL for frequently changing data, longer for stable data - */ -export const CACHE_TTL_STRATEGIES = { - // Real-time data (changes frequently) - VISITORS: 60, // 1 minute - - // Frequently updated data - FOLLOWERS: 10 * 60, // 10 minutes - TOTAL_COMMITS: 10 * 60, // 10 minutes - - // Moderately updated data - REPOSITORIES: 2 * 60 * 60, // 2 hours - TOTAL_STARS: 2 * 60 * 60, // 2 hours - - // Stable data (rarely changes) - ORGANIZATION: 6 * 60 * 60, // 6 hours - LANGUAGES: 6 * 60 * 60, // 6 hours - - // Project badges (GitHub API frequently updated) - REPO_STARS: 10 * 60, // 10 minutes - REPO_FORKS: 10 * 60, // 10 minutes - REPO_WATCHERS: 10 * 60, // 10 minutes -}; - -/** - * Get optimal TTL for a badge type - */ -export function getOptimalTTL(badgeType: string): number { - const ttl = CACHE_TTL_STRATEGIES[badgeType.toUpperCase().replace('-', '_') as keyof typeof CACHE_TTL_STRATEGIES]; - return ttl || 10 * 60; // Default 10 minutes -} +/** + * Badge Cache Invalidation & Warming Utilities + * Manages cache lifecycle for optimal data freshness and performance + */ + +import { getBadgeCacheServiceSync } from '../../services/badge-cache.service.js'; +import { createLogger } from '../logs/logger.js'; + +const logger = createLogger({ service: 'BadgeCacheManager' }); + +/** + * Invalidate all badges for a user when GitHub data is refreshed + * Call this when a user profile is updated from GitHub API + */ +export async function invalidateUserBadgeCache(username: string): Promise { + const badgeService = getBadgeCacheServiceSync(); + if (!badgeService?.isReady()) { + logger.debug('Badge cache not available for invalidation', { username }); + return; + } + + try { + await badgeService.invalidateUserBadges(username); + logger.info('User badge cache invalidated', { username }); + } catch (error) { + logger.error('Failed to invalidate user badge cache', error as Error, { username }); + } +} + +/** + * Invalidate all badges for a project when repo data is refreshed + * Call this when repository stats are updated from GitHub API + */ +export async function invalidateProjectBadgeCache(owner: string, repo: string): Promise { + const badgeService = getBadgeCacheServiceSync(); + if (!badgeService?.isReady()) { + logger.debug('Badge cache not available for invalidation', { owner, repo }); + return; + } + + try { + await badgeService.invalidateProjectBadges(owner, repo); + logger.info('Project badge cache invalidated', { owner, repo }); + } catch (error) { + logger.error('Failed to invalidate project badge cache', error as Error, { owner, repo }); + } +} + +/** + * Warm up cache with popular badges to reduce cold starts + * Should be triggered during application startup or maintenance windows + */ +export async function warmupBadgeCache( + usernames: string[] = [], + projects: Array<{ owner: string; repo: string }> = [], +): Promise<{ warmed: number; errors: number }> { + const badgeService = getBadgeCacheServiceSync(); + if (!badgeService?.isReady()) { + logger.warn('Badge cache not available for warmup'); + return { warmed: 0, errors: 0 }; + } + + let warmed = 0; + let errors = 0; + + logger.info('Starting badge cache warmup', { usernames: usernames.length, projects: projects.length }); + + // Warmup user badges + for (const username of usernames) { + try { + // Note: This is a placeholder. In a real scenario, you'd want to trigger + // the actual badge endpoints to populate the cache + logger.debug('User badge warmup queued', { username }); + warmed++; + } catch (error) { + logger.error('Failed to warmup user badge', error as Error, { username }); + errors++; + } + } + + // Warmup project badges + for (const { owner, repo } of projects) { + try { + logger.debug('Project badge warmup queued', { owner, repo }); + warmed++; + } catch (error) { + logger.error('Failed to warmup project badge', error as Error, { owner, repo }); + errors++; + } + } + + logger.info('Badge cache warmup completed', { warmed, errors }); + return { warmed, errors }; +} + +/** + * Get cache statistics and health info + */ +export async function getCacheStats(): Promise<{ + connected: boolean; + dbSize?: number; + memory?: string; + health: 'healthy' | 'degraded' | 'offline'; +}> { + const badgeService = getBadgeCacheServiceSync(); + if (!badgeService) { + return { + connected: false, + health: 'offline', + }; + } + + try { + const stats = await badgeService.getStats(); + if (!stats) { + return { + connected: false, + health: 'offline', + }; + } + + return { + connected: stats.connected, + dbSize: stats.dbSize, + memory: stats.memory, + health: stats.connected ? 'healthy' : 'offline', + }; + } catch (error) { + logger.error('Failed to get cache stats', error as Error); + return { + connected: false, + health: 'offline', + }; + } +} + +/** + * Implement TTL-based cache invalidation strategy + * Smaller TTL for frequently changing data, longer for stable data + */ +export const CACHE_TTL_STRATEGIES = { + // Real-time data (changes frequently) + VISITORS: 60, // 1 minute + + // Frequently updated data + FOLLOWERS: 10 * 60, // 10 minutes + TOTAL_COMMITS: 10 * 60, // 10 minutes + + // Moderately updated data + REPOSITORIES: 2 * 60 * 60, // 2 hours + TOTAL_STARS: 2 * 60 * 60, // 2 hours + + // Stable data (rarely changes) + ORGANIZATION: 6 * 60 * 60, // 6 hours + LANGUAGES: 6 * 60 * 60, // 6 hours + + // Project badges (GitHub API frequently updated) + REPO_STARS: 10 * 60, // 10 minutes + REPO_FORKS: 10 * 60, // 10 minutes + REPO_WATCHERS: 10 * 60, // 10 minutes +}; + +/** + * Get optimal TTL for a badge type + */ +export function getOptimalTTL(badgeType: string): number { + const ttl = CACHE_TTL_STRATEGIES[badgeType.toUpperCase().replace('-', '_') as keyof typeof CACHE_TTL_STRATEGIES]; + return ttl || 10 * 60; // Default 10 minutes +} diff --git a/src/utils/cache-middleware.ts b/src/shared/utils/cache-middleware.ts similarity index 96% rename from src/utils/cache-middleware.ts rename to src/shared/utils/cache-middleware.ts index 439af6c..994000d 100644 --- a/src/utils/cache-middleware.ts +++ b/src/shared/utils/cache-middleware.ts @@ -1,176 +1,176 @@ -import { Request, Response, NextFunction } from 'express'; -import { getCacheValue, setCacheValue } from './cache.js'; - -export interface CacheMiddlewareOptions { - keyGenerator: (req: Request) => string; - ttl?: number; - responseHeaders?: (req: Request) => Record; -} - -type Deferred = { - promise: Promise; - resolve: (value: T) => void; - reject: (error: Error) => void; -}; - -const inFlightRequests = new Map>(); - -function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; -} - -function respondWithCached(res: Response, cached: unknown) { - if (typeof cached === 'string') { - return res.send(cached); - } - - return res.json(cached); -} - -/** - * Express middleware for caching GET requests responses - * Usage example: - * app.get('/api/stats', - * cacheMiddleware({ - * keyGenerator: (req) => `stats:${req.query.username}`, - * ttl: 3600 - * }), - * controller - * ); - */ -export function cacheMiddleware(options: CacheMiddlewareOptions) { - return async (req: Request, res: Response, next: NextFunction) => { - // Only cache GET requests - if (req.method !== 'GET') { - return next(); - } - - const cacheKey = options.keyGenerator(req); - - // Skip caching if no valid cache key - if (!cacheKey) { - return next(); - } - - try { - // Try to get from cache - const cachedResponse = await getCacheValue(cacheKey); - if (cachedResponse) { - const headers = options.responseHeaders?.(req); - if (headers) { - Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value)); - } - res.set('X-Cache', 'HIT'); - return respondWithCached(res, cachedResponse); - } - } catch (error) { - console.error('Cache retrieval error:', error); - // Continue without cache if there's an error - } - - const inFlight = inFlightRequests.get(cacheKey); - if (inFlight) { - try { - const sharedResponse = await inFlight; - const headers = options.responseHeaders?.(req); - if (headers) { - Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value)); - } - res.set('X-Cache', 'COALESCED'); - return respondWithCached(res, sharedResponse); - } catch (error) { - console.warn('Coalesced request failed:', error); - } - } - - const deferred = createDeferred(); - inFlightRequests.set(cacheKey, deferred.promise); - let responseResolved = false; - - // Intercept the response - const originalJson = res.json.bind(res); - const originalSend = res.send.bind(res); - - const handleResponse = (data: unknown, responder: (payload: any) => Response) => { - responseResolved = true; - deferred.resolve(data); - inFlightRequests.delete(cacheKey); - - // Cache the response for future requests - setCacheValue(cacheKey, data, { ttl: options.ttl }).catch(error => { - console.error('Cache storage error:', error); - }); - - res.set('X-Cache', 'MISS'); - return responder(data as any); - }; - - res.json = function (data: any) { - return handleResponse(data, originalJson); - }; - - res.send = function (data: any) { - return handleResponse(data, originalSend); - }; - - res.on('close', () => { - if (responseResolved) { - return; - } - - inFlightRequests.delete(cacheKey); - deferred.reject(new Error('Response closed before sending.')); - }); - - next(); - }; -} - -/** - * Cache decorator for controller methods - * Useful for non-middleware caching - */ -export function withCache( - keyGenerator: (...args: T) => string, - ttl: number = 3600 -) { - return function ( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor - ) { - const originalMethod = descriptor.value; - - descriptor.value = async function (...args: T) { - const cacheKey = keyGenerator(...args); - - try { - const cached = await getCacheValue(cacheKey); - if (cached) { - return cached; - } - } catch (error) { - console.error('Cache retrieval error:', error); - } - - const result = await originalMethod.apply(this, args); - - try { - await setCacheValue(cacheKey, result, { ttl }); - } catch (error) { - console.error('Cache storage error:', error); - } - - return result; - }; - - return descriptor; - }; -} +import { Request, Response, NextFunction } from 'express'; +import { getCacheValue, setCacheValue } from './cache.js'; + +export interface CacheMiddlewareOptions { + keyGenerator: (req: Request) => string; + ttl?: number; + responseHeaders?: (req: Request) => Record; +} + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +}; + +const inFlightRequests = new Map>(); + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function respondWithCached(res: Response, cached: unknown) { + if (typeof cached === 'string') { + return res.send(cached); + } + + return res.json(cached); +} + +/** + * Express middleware for caching GET requests responses + * Usage example: + * app.get('/api/stats', + * cacheMiddleware({ + * keyGenerator: (req) => `stats:${req.query.username}`, + * ttl: 3600 + * }), + * controller + * ); + */ +export function cacheMiddleware(options: CacheMiddlewareOptions) { + return async (req: Request, res: Response, next: NextFunction) => { + // Only cache GET requests + if (req.method !== 'GET') { + return next(); + } + + const cacheKey = options.keyGenerator(req); + + // Skip caching if no valid cache key + if (!cacheKey) { + return next(); + } + + try { + // Try to get from cache + const cachedResponse = await getCacheValue(cacheKey); + if (cachedResponse) { + const headers = options.responseHeaders?.(req); + if (headers) { + Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value)); + } + res.set('X-Cache', 'HIT'); + return respondWithCached(res, cachedResponse); + } + } catch (error) { + console.error('Cache retrieval error:', error); + // Continue without cache if there's an error + } + + const inFlight = inFlightRequests.get(cacheKey); + if (inFlight) { + try { + const sharedResponse = await inFlight; + const headers = options.responseHeaders?.(req); + if (headers) { + Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value)); + } + res.set('X-Cache', 'COALESCED'); + return respondWithCached(res, sharedResponse); + } catch (error) { + console.warn('Coalesced request failed:', error); + } + } + + const deferred = createDeferred(); + inFlightRequests.set(cacheKey, deferred.promise); + let responseResolved = false; + + // Intercept the response + const originalJson = res.json.bind(res); + const originalSend = res.send.bind(res); + + const handleResponse = (data: unknown, responder: (payload: any) => Response) => { + responseResolved = true; + deferred.resolve(data); + inFlightRequests.delete(cacheKey); + + // Cache the response for future requests + setCacheValue(cacheKey, data, { ttl: options.ttl }).catch(error => { + console.error('Cache storage error:', error); + }); + + res.set('X-Cache', 'MISS'); + return responder(data as any); + }; + + res.json = function (data: any) { + return handleResponse(data, originalJson); + }; + + res.send = function (data: any) { + return handleResponse(data, originalSend); + }; + + res.on('close', () => { + if (responseResolved) { + return; + } + + inFlightRequests.delete(cacheKey); + deferred.reject(new Error('Response closed before sending.')); + }); + + next(); + }; +} + +/** + * Cache decorator for controller methods + * Useful for non-middleware caching + */ +export function withCache( + keyGenerator: (...args: T) => string, + ttl: number = 3600 +) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor + ) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: T) { + const cacheKey = keyGenerator(...args); + + try { + const cached = await getCacheValue(cacheKey); + if (cached) { + return cached; + } + } catch (error) { + console.error('Cache retrieval error:', error); + } + + const result = await originalMethod.apply(this, args); + + try { + await setCacheValue(cacheKey, result, { ttl }); + } catch (error) { + console.error('Cache storage error:', error); + } + + return result; + }; + + return descriptor; + }; +} diff --git a/src/utils/cache.ts b/src/shared/utils/cache.ts similarity index 96% rename from src/utils/cache.ts rename to src/shared/utils/cache.ts index 700dbff..d4bfede 100644 --- a/src/utils/cache.ts +++ b/src/shared/utils/cache.ts @@ -1,176 +1,176 @@ -import { getRedisClient, isRedisConnected, DEFAULT_TTL } from './redis-client.js'; - -export interface CacheOptions { - ttl?: number; // Time to live in seconds -} - -/** - * Get a value from Redis cache - */ -export async function getCacheValue(key: string): Promise { - if (!isRedisConnected()) { - return null; - } - - try { - const client = await getRedisClient(); - const value = await client.get(key); - - if (!value) { - return null; - } - - return JSON.parse(value) as T; - } catch (error) { - console.error(`Error retrieving cache for key ${key}:`, error); - return null; - } -} - -/** - * Set a value in Redis cache with optional TTL - */ -export async function setCacheValue( - key: string, - value: any, - options: CacheOptions = {} -): Promise { - if (!isRedisConnected()) { - return false; - } - - try { - const client = await getRedisClient(); - const ttl = options.ttl || DEFAULT_TTL.USER_DATA; - - await client.setEx(key, ttl, JSON.stringify(value)); - return true; - } catch (error) { - console.error(`Error setting cache for key ${key}:`, error); - return false; - } -} - -/** - * Delete a value from Redis cache - */ -export async function deleteCacheValue(key: string): Promise { - if (!isRedisConnected()) { - return false; - } - - try { - const client = await getRedisClient(); - await client.del(key); - return true; - } catch (error) { - console.error(`Error deleting cache for key ${key}:`, error); - return false; - } -} - -/** - * Delete multiple values from Redis cache - */ -export async function deleteCacheValues(keys: string[]): Promise { - if (!isRedisConnected() || keys.length === 0) { - return false; - } - - try { - const client = await getRedisClient(); - await client.del(keys); - return true; - } catch (error) { - console.error(`Error deleting cache values:`, error); - return false; - } -} - -/** - * Clear all cache entries for a specific user (pattern-based deletion) - */ -export async function clearUserCache(username: string): Promise { - if (!isRedisConnected()) { - return false; - } - - try { - const client = await getRedisClient(); - const keys = await client.keys(`*:${username}:*`); - - if (keys.length > 0) { - await client.del(keys); - } - - return true; - } catch (error) { - console.error(`Error clearing cache for user ${username}:`, error); - return false; - } -} - -/** - * Get or set cache value with a fallback function - * Useful for cache-aside pattern - */ -export async function getCacheOrCompute( - key: string, - computeFn: () => Promise, - options: CacheOptions = {} -): Promise { - try { - // Try to get from cache first - const cached = await getCacheValue(key); - if (cached !== null) { - return cached; - } - - // If not in cache, compute the value - const value = await computeFn(); - - // Store in cache for future requests - await setCacheValue(key, value, options); - - return value; - } catch (error) { - console.error(`Error in getCacheOrCompute for key ${key}:`, error); - // If all else fails, return the computed value - return computeFn(); - } -} - -/** - * Increment a counter in Redis (useful for visitor counts, etc.) - */ -export async function incrementCounter(key: string, amount: number = 1): Promise { - if (!isRedisConnected()) { - return 0; - } - - try { - const client = await getRedisClient(); - return await client.incrBy(key, amount); - } catch (error) { - console.error(`Error incrementing counter ${key}:`, error); - return 0; - } -} - -/** - * Set expiration on an existing key - */ -export async function setExpiration(key: string, ttl: number): Promise { - if (!isRedisConnected()) { - return false; - } - - try { - const client = await getRedisClient(); - const result = await client.expire(key, ttl); - return result === true; - } catch (error) { - console.error(`Error setting expiration for key ${key}:`, error); - return false; - } -} +import { getRedisClient, isRedisConnected, DEFAULT_TTL } from './redis-client.js'; + +export interface CacheOptions { + ttl?: number; // Time to live in seconds +} + +/** + * Get a value from Redis cache + */ +export async function getCacheValue(key: string): Promise { + if (!isRedisConnected()) { + return null; + } + + try { + const client = await getRedisClient(); + const value = await client.get(key); + + if (!value) { + return null; + } + + return JSON.parse(value) as T; + } catch (error) { + console.error(`Error retrieving cache for key ${key}:`, error); + return null; + } +} + +/** + * Set a value in Redis cache with optional TTL + */ +export async function setCacheValue( + key: string, + value: any, + options: CacheOptions = {} +): Promise { + if (!isRedisConnected()) { + return false; + } + + try { + const client = await getRedisClient(); + const ttl = options.ttl || DEFAULT_TTL.USER_DATA; + + await client.setEx(key, ttl, JSON.stringify(value)); + return true; + } catch (error) { + console.error(`Error setting cache for key ${key}:`, error); + return false; + } +} + +/** + * Delete a value from Redis cache + */ +export async function deleteCacheValue(key: string): Promise { + if (!isRedisConnected()) { + return false; + } + + try { + const client = await getRedisClient(); + await client.del(key); + return true; + } catch (error) { + console.error(`Error deleting cache for key ${key}:`, error); + return false; + } +} + +/** + * Delete multiple values from Redis cache + */ +export async function deleteCacheValues(keys: string[]): Promise { + if (!isRedisConnected() || keys.length === 0) { + return false; + } + + try { + const client = await getRedisClient(); + await client.del(keys); + return true; + } catch (error) { + console.error(`Error deleting cache values:`, error); + return false; + } +} + +/** + * Clear all cache entries for a specific user (pattern-based deletion) + */ +export async function clearUserCache(username: string): Promise { + if (!isRedisConnected()) { + return false; + } + + try { + const client = await getRedisClient(); + const keys = await client.keys(`*:${username}:*`); + + if (keys.length > 0) { + await client.del(keys); + } + + return true; + } catch (error) { + console.error(`Error clearing cache for user ${username}:`, error); + return false; + } +} + +/** + * Get or set cache value with a fallback function + * Useful for cache-aside pattern + */ +export async function getCacheOrCompute( + key: string, + computeFn: () => Promise, + options: CacheOptions = {} +): Promise { + try { + // Try to get from cache first + const cached = await getCacheValue(key); + if (cached !== null) { + return cached; + } + + // If not in cache, compute the value + const value = await computeFn(); + + // Store in cache for future requests + await setCacheValue(key, value, options); + + return value; + } catch (error) { + console.error(`Error in getCacheOrCompute for key ${key}:`, error); + // If all else fails, return the computed value + return computeFn(); + } +} + +/** + * Increment a counter in Redis (useful for visitor counts, etc.) + */ +export async function incrementCounter(key: string, amount: number = 1): Promise { + if (!isRedisConnected()) { + return 0; + } + + try { + const client = await getRedisClient(); + return await client.incrBy(key, amount); + } catch (error) { + console.error(`Error incrementing counter ${key}:`, error); + return 0; + } +} + +/** + * Set expiration on an existing key + */ +export async function setExpiration(key: string, ttl: number): Promise { + if (!isRedisConnected()) { + return false; + } + + try { + const client = await getRedisClient(); + const result = await client.expire(key, ttl); + return result === true; + } catch (error) { + console.error(`Error setting expiration for key ${key}:`, error); + return false; + } +} diff --git a/src/shared/utils/css-color.ts b/src/shared/utils/css-color.ts new file mode 100644 index 0000000..a1bfcfb --- /dev/null +++ b/src/shared/utils/css-color.ts @@ -0,0 +1,63 @@ +/** + * Strict CSS color validator used by the /icons endpoints (M2). + * + * Replaces the previous permissive `[a-zA-Z]+` and `rgb\([^)]+\)` branches + * with structured parsers and an explicit CSS3 named-color allowlist so + * users can't cram arbitrary letter-only tokens into `fill="โ€ฆ"` output. + */ + +const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +// `\d{1,3}` is deliberately loose on range โ€” we're validating format, not +// clamping values (a `300` in rgb() is a broken color, not an injection). +const RGB_RE = /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/; +const RGBA_RE = /^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0|1|0?\.\d+)\s*\)$/; +const HSL_RE = /^hsl\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*\)$/; +const HSLA_RE = /^hsla\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*,\s*(0|1|0?\.\d+)\s*\)$/; + +/** CSS3 named colors (148 entries), lowercase. `currentColor` is handled + * separately because it's not a "color name" per the CSS spec. */ +const NAMED_COLORS: ReadonlySet = new Set([ + 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', + 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', + 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', + 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', + 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', + 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', + 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', + 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', + 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', + 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', + 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', + 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', + 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', + 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', + 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', + 'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', + 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', + 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', + 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', + 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', + 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', + 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', + 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', + 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', + 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', + 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', + 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', + 'transparent', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', + 'yellow', 'yellowgreen', +]); + +export function isValidCssColor(value: string): boolean { + if (typeof value !== 'string' || value.length === 0) return false; + + // `currentColor` is case-insensitive in practice. + if (value.toLowerCase() === 'currentcolor') return true; + + if (HEX_RE.test(value)) return true; + if (RGB_RE.test(value) || RGBA_RE.test(value)) return true; + if (HSL_RE.test(value) || HSLA_RE.test(value)) return true; + + return NAMED_COLORS.has(value.toLowerCase()); +} diff --git a/src/utils/github-client.ts b/src/shared/utils/github-client.ts similarity index 58% rename from src/utils/github-client.ts rename to src/shared/utils/github-client.ts index b97dae7..574edc7 100644 --- a/src/utils/github-client.ts +++ b/src/shared/utils/github-client.ts @@ -1,5 +1,7 @@ import { Octokit } from '@octokit/rest'; -import { GitHubStats, LanguageCount, BadgeType } from '../types.js'; +import { GitHubStats } from '../types/github.types.js'; +import { BadgeType } from '../types/badge.types.js'; +import { LanguageCount } from '../types/language.types.js'; /** Project/Repository-specific badge types */ export type RepoBadgeType = 'repo-stars' | 'repo-forks' | 'repo-watchers' | 'repo-issues' | 'repo-prs' | 'repo-contributors' | 'repo-size'; @@ -47,16 +49,43 @@ export class GitHubClient { this.pendingRequests.clear(); } + /** + * Shared endpoint helpers (single cache key per endpoint) + */ + private async fetchUserProfile(username: string): Promise { + return this.cachedRequest(`user-profile-${username}`, async () => { + const { data } = await this.octokit.users.getByUsername({ username }); + return data; + }); + } + + private async fetchUserRepoList(username: string): Promise { + return this.cachedRequest(`user-repos-${username}`, async () => { + const { data } = await this.octokit.repos.listForUser({ + username, per_page: 100, type: 'owner', + }); + return data; + }); + } + + private async fetchRepoData(owner: string, repo: string): Promise { + return this.cachedRequest(`repo-data-${owner}-${repo}`, async () => { + const { data } = await this.octokit.repos.get({ owner, repo }); + return data; + }); + } + // Return default stats for user not found private getDefaultStats(username: string): GitHubStats { return { name: username, - avatarUrl: `https://avatars.githubusercontent.com/u/0?v=4?s=130`, + avatarUrl: 'https://avatars.githubusercontent.com/u/0?v=4', totalStars: 0, totalCommits: 0, totalPRs: 0, totalIssues: 0, contributedTo: 0, + totalContributions: 0, rank: { level: 'F', score: 0, @@ -64,9 +93,167 @@ export class GitHubClient { }; } - async fetchUserStats(username: string, options: { avatarMode: 'none' | 'avatar' | 'radar' }): Promise { + private buildContributionYearRanges(createdAt: Date): Array<{ from: string; to: string }> { + const now = new Date(); + const years: Array<{ from: string; to: string }> = []; + let yearStart = new Date(createdAt.getFullYear(), 0, 1); + + while (yearStart <= now) { + const yearEnd = new Date(yearStart.getFullYear(), 11, 31, 23, 59, 59); + years.push({ + from: (yearStart > createdAt ? yearStart : createdAt).toISOString(), + to: (yearEnd > now ? now : yearEnd).toISOString(), + }); + yearStart = new Date(yearStart.getFullYear() + 1, 0, 1); + } + + return years; + } + + /** + * Sum `contributionCalendar.totalContributions` across every year from the + * account's creation date to today. Used by /graph to display an all-time + * total alongside the (still one-year-wide) heatmap. + */ + async fetchTotalContributionsSinceCreated(username: string): Promise { + const key = `user-total-contribs-since-created-${username}`; + return this.cachedRequest(key, async () => { + const profile = await this.fetchUserProfile(username); + if (!profile?.created_at) return 0; + + const ranges = this.buildContributionYearRanges(new Date(profile.created_at)); + if (ranges.length === 0) return 0; + + const variableDefinitions = ranges + .map((_, index) => `$from${index}: DateTime!, $to${index}: DateTime!`) + .join(', '); + const contributionSelections = ranges + .map((_, index) => `year${index}: contributionsCollection(from: $from${index}, to: $to${index}) { contributionCalendar { totalContributions } }`) + .join('\n'); + + const query = ` + query($username: String!, ${variableDefinitions}) { + user(login: $username) { + ${contributionSelections} + } + } + `; + + const variables: Record = { username }; + ranges.forEach((range, index) => { + variables[`from${index}`] = range.from; + variables[`to${index}`] = range.to; + }); + + const result: any = await this.octokit.graphql(query, variables); + const user = result.user; + if (!user) return 0; + + return ranges.reduce((sum, _, index) => { + return sum + (user[`year${index}`]?.contributionCalendar?.totalContributions ?? 0); + }, 0); + }); + } + + /** + * Fetch commits / PRs / issues / calendar total scoped to a single calendar + * year. Used when `/stats` is requested with `?year=YYYY`. `restricted` + * (private-repo contribution count) is folded into `commits` to mirror + * `fetchTotalCommitContributions` behavior. + */ + private async fetchYearContributions(username: string, year: number): Promise<{ + commits: number; + prs: number; + issues: number; + total: number; + }> { + return this.cachedRequest(`user-year-contribs-${username}-${year}`, async () => { + const from = new Date(Date.UTC(year, 0, 1)).toISOString(); + const to = new Date(Date.UTC(year, 11, 31, 23, 59, 59)).toISOString(); + + const query = ` + query($username: String!, $from: DateTime!, $to: DateTime!) { + user(login: $username) { + contributionsCollection(from: $from, to: $to) { + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + restrictedContributionsCount + contributionCalendar { totalContributions } + } + } + } + `; + + const result: any = await this.octokit.graphql(query, { username, from, to }); + const cc = result?.user?.contributionsCollection; + if (!cc) return { commits: 0, prs: 0, issues: 0, total: 0 }; + + return { + commits: (cc.totalCommitContributions || 0) + (cc.restrictedContributionsCount || 0), + prs: cc.totalPullRequestContributions || 0, + issues: cc.totalIssueContributions || 0, + total: cc.contributionCalendar?.totalContributions || 0, + }; + }); + } + + private async fetchTotalCommitContributions(username: string, createdAt: string): Promise { + const ranges = this.buildContributionYearRanges(new Date(createdAt)); + + if (ranges.length === 0) { + return 0; + } + + const variableDefinitions = ranges + .map((_, index) => `$from${index}: DateTime!, $to${index}: DateTime!`) + .join(', '); + const contributionSelections = ranges + .map((_, index) => `year${index}: contributionsCollection(from: $from${index}, to: $to${index}) { totalCommitContributions restrictedContributionsCount }`) + .join('\n'); + + const query = ` + query($username: String!, ${variableDefinitions}) { + user(login: $username) { + ${contributionSelections} + } + } + `; + + const variables: Record = { username }; + ranges.forEach((range, index) => { + variables[`from${index}`] = range.from; + variables[`to${index}`] = range.to; + }); + + const result: any = await this.octokit.graphql(query, variables); + const user = result.user; + + if (!user) { + return 0; + } + + return ranges.reduce((sum, _, index) => { + const contributionYear = user[`year${index}`]; + return sum + (contributionYear?.totalCommitContributions || 0) + (contributionYear?.restrictedContributionsCount || 0); + }, 0); + } + + private withAvatarMode(stats: GitHubStats, avatarMode: 'none' | 'avatar' | 'radar'): GitHubStats { + if (avatarMode === 'none') { + return stats; + } + + return { + ...stats, + avatarUrl: `${stats.avatarUrl}${stats.avatarUrl.includes('?') ? '&' : '?'}s=130`, + }; + } + + async fetchUserStats(username: string, options: { avatarMode: 'none' | 'avatar' | 'radar'; year?: number }): Promise { + const yearKey = options.year ? `-y${options.year}` : ''; try { - return await this.cachedRequest(`user-stats-${username}`, async () => { + const stats = await this.cachedRequest(`user-stats-${username}${yearKey}`, async () => { // Use GraphQL to get all-time stats in a single request const query = ` query($username: String!) { @@ -118,76 +305,40 @@ export class GitHubClient { // Count non-fork repositories const contributedTo = userData.repositories.nodes.filter((repo: any) => !repo.isFork).length; - // Get real PR and issue counts - const totalPRs = userData.pullRequests.totalCount; - const totalIssues = userData.issues.totalCount; + // When `year` is set we scope commits/PRs/issues/contributions + // to that year via a single `contributionsCollection`. Stars + // remain all-time (GitHub can't cheaply time-slice stargazers). + const yearScoped = options.year + ? await this.fetchYearContributions(username, options.year) + : null; - // Get all-time commits by summing contributions from account creation to now - const createdAt = new Date(userData.createdAt); - const now = new Date(); - let totalCommits = 0; + const totalPRs = yearScoped ? yearScoped.prs : userData.pullRequests.totalCount; + const totalIssues = yearScoped ? yearScoped.issues : userData.issues.totalCount; - // Fetch commits year by year (GitHub only allows 1 year at a time for contributionsCollection) - const years: { from: Date; to: Date }[] = []; - let yearStart = new Date(createdAt.getFullYear(), 0, 1); - - while (yearStart <= now) { - const yearEnd = new Date(yearStart.getFullYear(), 11, 31, 23, 59, 59); - years.push({ - from: yearStart > createdAt ? yearStart : createdAt, - to: yearEnd > now ? now : yearEnd - }); - yearStart = new Date(yearStart.getFullYear() + 1, 0, 1); - } - - // Fetch all years' contributions in parallel - const commitPromises = years.map(async ({ from, to }) => { - const commitQuery = ` - query($username: String!, $from: DateTime!, $to: DateTime!) { - user(login: $username) { - contributionsCollection(from: $from, to: $to) { - totalCommitContributions - restrictedContributionsCount - } - } - } - `; - try { - const result: any = await this.octokit.graphql(commitQuery, { - username, - from: from.toISOString(), - to: to.toISOString() - }); - const collection = result.user?.contributionsCollection; - return (collection?.totalCommitContributions || 0) + (collection?.restrictedContributionsCount || 0); - } catch { - return 0; - } - }); - - const yearlyCommits = await Promise.all(commitPromises); - totalCommits = yearlyCommits.reduce((sum, count) => sum + count, 0); + const [totalCommits, totalContributions] = yearScoped + ? [yearScoped.commits, yearScoped.total] + : await Promise.all([ + this.fetchTotalCommitContributions(username, userData.createdAt), + this.fetchTotalContributionsSinceCreated(username), + ]); // Calculate rank const rank = this.calculateRank(totalStars, totalCommits, totalPRs, totalIssues); - // Format avatar URL - let avatarUrl = userData.avatarUrl; - if (options.avatarMode !== 'none') { - avatarUrl = `${userData.avatarUrl}${userData.avatarUrl.includes('?') ? '&' : '?'}s=130`; - } - return { name: userData.name || username, - avatarUrl, + avatarUrl: userData.avatarUrl, totalStars, totalCommits, totalPRs, totalIssues, contributedTo, + totalContributions, rank, }; }); + + return this.withAvatarMode(stats, options.avatarMode); } catch (error: any) { // Check if it's a rate limit error if (error.status === 403 && error.message?.includes('rate limit')) { @@ -217,14 +368,10 @@ export class GitHubClient { async fetchUserLanguages(username: string): Promise { try { return await this.cachedRequest(`user-langs-${username}`, async () => { - const { data: repos } = await this.octokit.repos.listForUser({ - username, - per_page: 100, - type: 'owner', - }); + const repos = await this.fetchUserRepoList(username); const languageCounts = new Map(); - repos.forEach(repo => { + repos.forEach((repo: any) => { if (!repo.language) return; const current = languageCounts.get(repo.language) || 0; languageCounts.set(repo.language, current + 1); @@ -336,66 +483,61 @@ export class GitHubClient { const key = `badge-${type}-${username}`; switch (type) { - // โ”€โ”€ profile fields (single user request) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + /** + * Profile fields (single user request) + */ case 'repositories': - return this.cachedRequest(key, async () => { - const { data } = await this.octokit.users.getByUsername({ username }); - return data.public_repos; - }); + return (await this.fetchUserProfile(username)).public_repos; case 'followers': - return this.cachedRequest(key, async () => { - const { data } = await this.octokit.users.getByUsername({ username }); - return data.followers; - }); + return (await this.fetchUserProfile(username)).followers; - case 'total-joined-years': - return this.cachedRequest(key, async () => { - const { data } = await this.octokit.users.getByUsername({ username }); - const joinedYear = new Date(data.created_at).getFullYear(); - return new Date().getFullYear() - joinedYear; - }); + case 'total-joined-years': { + const joinedYear = new Date((await this.fetchUserProfile(username)).created_at).getFullYear(); + return new Date().getFullYear() - joinedYear; + } - // โ”€โ”€ organization membership โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + /** + * Organization membership + */ case 'organization': return this.cachedRequest(key, async () => { const { data } = await this.octokit.orgs.listForUser({ username, per_page: 100 }); return data.length; }); - // โ”€โ”€ derived from repo list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + /** + * Derived from repo list + */ case 'languages': { const langs = await this.fetchUserLanguages(username); return langs.length; } - case 'total-stars': - return this.cachedRequest(key, async () => { - const { data: repos } = await this.octokit.repos.listForUser({ - username, per_page: 100, type: 'owner', - }); - return repos.reduce((acc, r) => acc + (r.stargazers_count ?? 0), 0); - }); + case 'total-stars': { + const repos = await this.fetchUserRepoList(username); + return repos.reduce((acc: number, r: any) => acc + (r.stargazers_count ?? 0), 0); + } case 'total-contributors': return this.cachedRequest(key, async () => { - // Fetch top-10 most-starred repos to keep API calls manageable - const { data: repos } = await this.octokit.repos.listForUser({ - username, per_page: 10, type: 'owner', sort: 'pushed', direction: 'desc', - }); + const allRepos = await this.fetchUserRepoList(username); + const topRepos = allRepos.slice(0, 10); const unique = new Set(); - for (const repo of repos) { + await Promise.all(topRepos.map(async (repo: any) => { try { const { data: contribs } = await this.octokit.repos.listContributors({ owner: username, repo: repo.name, per_page: 100, }); - contribs.forEach(c => c.login && unique.add(c.login)); + contribs.forEach((c: any) => c.login && unique.add(c.login)); } catch { /* non-critical โ€“ skip unavailable repos */ } - } + })); return unique.size; }); - // โ”€โ”€ search API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + /** + * Search API + */ case 'total-commits': return this.cachedRequest(key, async () => { const { data } = await this.octokit.search.commits({ @@ -442,28 +584,16 @@ export class GitHubClient { try { switch (type) { case 'repo-stars': - return await this.cachedRequest(key, async () => { - const { data } = await this.octokit.repos.get({ owner, repo }); - return data.stargazers_count; - }); + return (await this.fetchRepoData(owner, repo)).stargazers_count; case 'repo-forks': - return await this.cachedRequest(key, async () => { - const { data } = await this.octokit.repos.get({ owner, repo }); - return data.forks_count; - }); + return (await this.fetchRepoData(owner, repo)).forks_count; case 'repo-watchers': - return await this.cachedRequest(key, async () => { - const { data } = await this.octokit.repos.get({ owner, repo }); - return data.subscribers_count; - }); + return (await this.fetchRepoData(owner, repo)).subscribers_count; case 'repo-issues': - return await this.cachedRequest(key, async () => { - const { data } = await this.octokit.repos.get({ owner, repo }); - return data.open_issues_count; - }); + return (await this.fetchRepoData(owner, repo)).open_issues_count; case 'repo-prs': return await this.cachedRequest(key, async () => { @@ -476,12 +606,6 @@ export class GitHubClient { case 'repo-contributors': return await this.cachedRequest(key, async () => { - await this.octokit.repos.listContributors({ - owner, - repo, - per_page: 1, - anon: 'true', - }); const response = await this.octokit.repos.listContributors({ owner, repo, @@ -491,10 +615,7 @@ export class GitHubClient { }); case 'repo-size': - return await this.cachedRequest(key, async () => { - const { data } = await this.octokit.repos.get({ owner, repo }); - return data.size; // Size in KB - }); + return (await this.fetchRepoData(owner, repo)).size; default: throw new Error(`Unknown repo badge type: ${type}`); diff --git a/src/utils/global-error.ts b/src/shared/utils/global-error.ts similarity index 72% rename from src/utils/global-error.ts rename to src/shared/utils/global-error.ts index 5c29d2b..ab6ffc5 100644 --- a/src/utils/global-error.ts +++ b/src/shared/utils/global-error.ts @@ -1,4 +1,5 @@ import { closeRedisClient } from "./redis-client.js"; +import type { Server } from 'http'; /** * Format error for logging based on type @@ -18,8 +19,19 @@ const formatError = (err: unknown): string => { /** * Graceful exit with cleanup */ -const gracefulExit = async (code: number): Promise => { +const gracefulExit = async (code: number, server?: Server): Promise => { try { + // Close HTTP server + if (server) { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + // Close Redis connection await closeRedisClient(); } catch { // Ignore cleanup errors during shutdown @@ -45,10 +57,10 @@ export const getGlobalErrorHandlers = (): void => { /** * Setup graceful shutdown handlers */ -export const setupGracefulShutdown = (): void => { +export const setupGracefulShutdown = (server?: Server): void => { const shutdown = async (signal: string): Promise => { console.log(`\n๐Ÿ›‘ Received ${signal}. Shutting down gracefully...`); - await gracefulExit(0); + await gracefulExit(0, server); }; process.on('SIGINT', () => void shutdown('SIGINT')); diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 0000000..d9fec84 --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1,11 @@ +/** + * Shared Utilities + * Exports all utilities + */ + +export { GitHubClient } from './github-client.js'; +export { themes, badgeThemes, getTheme, getBadgeTheme } from './themes.js'; +export { getRedisClient, closeRedisClient, CACHE_KEYS } from './redis-client.js'; +export { cacheMiddleware } from './cache-middleware.js'; +export { warmupBadgeCache } from './badge-cache-manager.js'; +export { getGlobalErrorHandlers, setupGracefulShutdown } from './global-error.js'; diff --git a/src/utils/redis-client.ts b/src/shared/utils/redis-client.ts similarity index 95% rename from src/utils/redis-client.ts rename to src/shared/utils/redis-client.ts index 897fcf2..3992bc1 100644 --- a/src/utils/redis-client.ts +++ b/src/shared/utils/redis-client.ts @@ -1,280 +1,278 @@ -import { createClient, RedisClientType } from 'redis'; -import cluster from 'cluster'; - -let redisClient: RedisClientType | null = null; - -// Only log from worker 1 or non-cluster mode to reduce noise -const shouldLog = !cluster.isWorker || cluster.worker?.id === 1; - -/** - * Determine if TLS should be enabled based on environment and host - */ -function shouldEnableTLS(host: string | undefined): boolean { - // Explicit TLS setting takes priority - if (process.env.REDIS_TLS === 'true') return true; - if (process.env.REDIS_TLS === 'false') return false; - - // Auto-detect TLS for known cloud providers - if (!host) return false; - - const tlsHosts = [ - 'cloud.redislabs.com', // Redis Cloud - 'cache.amazonaws.com', // AWS ElastiCache - 'redis-enterprise.com', // Redis Enterprise - 'redislabs.com', // Redis Labs - 'render.com', // Render.com hosting - ]; - - return tlsHosts.some(tlsHost => host.includes(tlsHost)); -} - -/** - * Log connection details (sanitized for security) - */ -function logConnectionDetails(host: string, port: number, tls: boolean): void { - if (!shouldLog) return; - - const tlsStatus = tls ? '๐Ÿ”’ TLS Enabled' : 'โš ๏ธ TLS Disabled'; - console.log(`๐Ÿ“ก Redis Connection: ${host}:${port} (${tlsStatus})`); - - if (process.env.DEBUG_REDIS === 'true') { - console.log(`๐Ÿ” Debug: TLS=${tls}, Host=${host}, Port=${port}`); - } -} - -/** - * Build Redis URL from environment variables - * Useful when socket-based config has TLS issues - */ -function buildRedisUrl(host: string, port: number, username: string, password: string, useTls: boolean): string { - const protocol = useTls ? 'rediss' : 'redis'; - // URL encode the password in case it contains special characters - const encodedPassword = encodeURIComponent(password); - const encodedUsername = encodeURIComponent(username); - const dbIndex = getRedisDbIndex(); - const dbPath = dbIndex !== null ? `/${dbIndex}` : ''; - return `${protocol}://${encodedUsername}:${encodedPassword}@${host}:${port}${dbPath}`; -} - -function getRedisDbIndex(): number | null { - if (!process.env.REDIS_DB) { - return null; - } - - const dbIndex = Number.parseInt(process.env.REDIS_DB, 10); - if (Number.isNaN(dbIndex)) { - console.warn('โš ๏ธ REDIS_DB must be a number (database index).'); - return null; - } - - return dbIndex; -} - -export async function getRedisClient(): Promise { - // Check if Redis is explicitly disabled - if (process.env.REDIS_ENABLED === 'false') { - if (shouldLog) console.log('โš ๏ธ Redis is disabled (REDIS_ENABLED=false)'); - throw new Error('Redis is disabled'); - } - - if (redisClient) { - return redisClient; - } - - // Configuration priority: - // 1. Socket-based config (REDIS_HOST, REDIS_PORT, REDIS_USERNAME, REDIS_PASSWORD) - // 2. URL-based config (REDIS_URL) - // 3. Default local Redis - - const shouldUseSocketConfig = - process.env.REDIS_HOST || - process.env.REDIS_PORT || - process.env.REDIS_USERNAME || - process.env.REDIS_PASSWORD; - - if (shouldUseSocketConfig) { - // Socket-based configuration (for Redis Cloud, AWS ElastiCache, etc.) - const host = process.env.REDIS_HOST || 'localhost'; - const port = parseInt(process.env.REDIS_PORT || '6379', 10); - const username = process.env.REDIS_USERNAME || 'default'; - const password = process.env.REDIS_PASSWORD || ''; - const tls = shouldEnableTLS(host); - - logConnectionDetails(host, port, tls); - - try { - // For Redis Cloud and managed services, URL-based config often works better for TLS - // The rediss:// protocol (with double 's') handles TLS automatically - if (tls && host.includes('cloud.redislabs.com')) { - if (shouldLog) console.log(`๐Ÿ“ก Using URL-based config for better TLS handling with Redis Cloud...`); - const redisUrl = buildRedisUrl(host, port, username, password, true); - redisClient = createClient({ - url: redisUrl, - socket: { - tls: true, - rejectUnauthorized: false, // Allow self-signed certs - servername: host, // SNI support - } - }); - } else { - // Standard socket-based config for local/non-TLS scenarios - const socketConfig: any = { - host, - port, - }; - - if (tls) { - socketConfig.tls = { - rejectUnauthorized: false, // Allow self-signed certificates - servername: host, // Server Name Indication (SNI) - }; - } - - redisClient = createClient({ - username, - password, - socket: socketConfig, - }); - } - } catch (error) { - if (shouldLog) console.error('โŒ Failed to create Redis client with socket config:', error); - throw error; - } - } else { - // URL-based configuration (simpler approach) - const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; - if (shouldLog) console.log(`๐Ÿ“ก Redis Connection: Using REDIS_URL environment variable`); - - try { - redisClient = createClient({ - url: redisUrl, - }); - } catch (error) { - if (shouldLog) console.error('โŒ Failed to create Redis client with URL config:', error); - throw error; - } - } - - redisClient.on('error', (err) => { - if (shouldLog) { - // Only log TLS errors once to avoid spam - if (err.message && err.message.includes('packet length too long')) { - console.error('โŒ Redis TLS Error: Connection configuration mismatch'); - console.error('๐Ÿ’ก Hint: Try disabling Redis or check your TLS settings'); - console.error(' Set REDIS_ENABLED=false in .env to disable Redis'); - } else { - console.error('โŒ Redis Client Error:', err.message || err); - } - if (process.env.DEBUG_REDIS === 'true') { - console.error('Full error:', err); - } - } - }); - redisClient.on('connect', () => shouldLog && console.log('โœ… Redis Client Connected')); - redisClient.on('ready', () => shouldLog && console.log('โœ… Redis Client Ready')); - redisClient.on('reconnecting', () => shouldLog && console.log('๐Ÿ”„ Redis Client Reconnecting...')); - - try { - await redisClient.connect(); - } catch (error) { - console.error('โŒ Failed to connect to Redis:', error); - if (error instanceof Error && error.message.includes('packet length too long')) { - console.error('๐Ÿ’ก Hint: This error suggests a TLS/SSL mismatch.'); - console.error(' Try one of these solutions:'); - console.error(' 1. Try URL-based config: Set REDIS_URL=rediss://user:pass@host:port'); - console.error(' 2. For Redis Cloud: Use the standard TLS port (usually 10434)'); - console.error(' 3. Check your Redis Cloud dashboard for the exact connection string'); - console.error(' 4. Ensure your password doesn\'t have special characters or URL-encode it'); - } - throw error; - } - - const dbIndex = getRedisDbIndex(); - if (dbIndex !== null) { - try { - await redisClient.select(dbIndex); - console.log(`โœ… Redis database selected: ${dbIndex}`); - } catch (error) { - console.error('โŒ Failed to select Redis database:', error); - throw error; - } - } else if (process.env.REDIS_DB_NAME) { - console.warn('โš ๏ธ Redis uses numeric database indexes; REDIS_DB_NAME is ignored.'); - console.warn('โš ๏ธ Set REDIS_DB to a number (e.g., 0) if you need database selection.'); - } - - return redisClient; -} - -export async function closeRedisClient(): Promise { - if (redisClient) { - await redisClient.quit(); - redisClient = null; - } -} - -export function isRedisConnected(): boolean { - return redisClient !== null && redisClient.isOpen; -} - -// Cache key prefix to avoid collisions -export const CACHE_KEYS = { - STATS: (username: string) => `stats:${username}`, - LANGUAGES: (username: string) => `languages:${username}`, - GRAPH: (username: string, params: string) => `graph:${username}:${params}`, - BADGE_VISITORS: (username: string) => `badge:visitors:${username}`, - BADGE_REPOSITORIES: (username: string) => `badge:repositories:${username}`, - BADGE_ORGANIZATION: (username: string) => `badge:organization:${username}`, - BADGE_LANGUAGES: (username: string) => `badge:languages:${username}`, - BADGE_FOLLOWERS: (username: string) => `badge:followers:${username}`, - BADGE_TOTAL_STARS: (username: string) => `badge:total_stars:${username}`, - BADGE_TOTAL_CONTRIBUTORS: (username: string) => `badge:total_contributors:${username}`, - BADGE_TOTAL_COMMITS: (username: string) => `badge:total_commits:${username}`, - BADGE_TOTAL_CODE_REVIEWS: (username: string) => `badge:total_code_reviews:${username}`, - BADGE_TOTAL_ISSUES: (username: string) => `badge:total_issues:${username}`, - BADGE_TOTAL_PULL_REQUESTS: (username: string) => `badge:total_pull_requests:${username}`, - BADGE_TOTAL_JOINED_YEARS: (username: string) => `badge:total_joined_years:${username}`, - // Project/Repository-specific badge cache keys - BADGE_REPO_STARS: (owner: string, repo: string) => `badge:repo_stars:${owner}:${repo}`, - BADGE_REPO_FORKS: (owner: string, repo: string) => `badge:repo_forks:${owner}:${repo}`, - BADGE_REPO_WATCHERS: (owner: string, repo: string) => `badge:repo_watchers:${owner}:${repo}`, - BADGE_REPO_ISSUES: (owner: string, repo: string) => `badge:repo_issues:${owner}:${repo}`, - BADGE_REPO_PRS: (owner: string, repo: string) => `badge:repo_prs:${owner}:${repo}`, - BADGE_REPO_CONTRIBUTORS: (owner: string, repo: string) => `badge:repo_contributors:${owner}:${repo}`, - BADGE_REPO_SIZE: (owner: string, repo: string) => `badge:repo_size:${owner}:${repo}`, - USER_DATA: (username: string) => `user:${username}`, -}; - -// Default cache TTLs (in seconds) -export const DEFAULT_TTL = { - STATS: 7200, // 2 hours (increased from 1 hour for better hit rate) - LANGUAGES: 7200, // 2 hours (increased from 1 hour) - GRAPH: 3600, // 1 hour (increased from 30 minutes) - BADGE: 3600, // 1 hour (increased from 30 minutes) - USER_DATA: 7200, // 2 hours (increased from 1 hour) -}; - -/** - * Get badge cache key generator by badge type - * @param badgeType - The badge type (e.g., 'VISITORS', 'FOLLOWERS', 'TOTAL_STARS') - * @returns Cache key generator function or null if invalid type - */ -export function getBadgeCacheKey(badgeType: string): ((username: string) => string) | null { - const key = `BADGE_${badgeType}` as const; - const badgeKeys: Record string> = { - 'BADGE_VISITORS': CACHE_KEYS.BADGE_VISITORS, - 'BADGE_REPOSITORIES': CACHE_KEYS.BADGE_REPOSITORIES, - 'BADGE_ORGANIZATION': CACHE_KEYS.BADGE_ORGANIZATION, - 'BADGE_LANGUAGES': CACHE_KEYS.BADGE_LANGUAGES, - 'BADGE_FOLLOWERS': CACHE_KEYS.BADGE_FOLLOWERS, - 'BADGE_TOTAL_STARS': CACHE_KEYS.BADGE_TOTAL_STARS, - 'BADGE_TOTAL_CONTRIBUTORS': CACHE_KEYS.BADGE_TOTAL_CONTRIBUTORS, - 'BADGE_TOTAL_COMMITS': CACHE_KEYS.BADGE_TOTAL_COMMITS, - 'BADGE_TOTAL_CODE_REVIEWS': CACHE_KEYS.BADGE_TOTAL_CODE_REVIEWS, - 'BADGE_TOTAL_ISSUES': CACHE_KEYS.BADGE_TOTAL_ISSUES, - 'BADGE_TOTAL_PULL_REQUESTS': CACHE_KEYS.BADGE_TOTAL_PULL_REQUESTS, - 'BADGE_TOTAL_JOINED_YEARS': CACHE_KEYS.BADGE_TOTAL_JOINED_YEARS, - }; - - return badgeKeys[key] || null; -} +import { createClient, RedisClientType } from 'redis'; +import cluster from 'cluster'; + +let redisClient: RedisClientType | null = null; + +// Only log from worker 1 or non-cluster mode to reduce noise +const shouldLog = !cluster.isWorker || cluster.worker?.id === 1; + +/** + * Determine if TLS should be enabled based on environment and host + */ +function shouldEnableTLS(host: string | undefined): boolean { + // Explicit TLS setting takes priority + if (process.env.REDIS_TLS === 'true') return true; + if (process.env.REDIS_TLS === 'false') return false; + + // Auto-detect TLS for known cloud providers + if (!host) return false; + + const tlsHosts = [ + 'cloud.redislabs.com', // Redis Cloud + 'cache.amazonaws.com', // AWS ElastiCache + 'redis-enterprise.com', // Redis Enterprise + 'redislabs.com', // Redis Labs + 'render.com', // Render.com hosting + ]; + + return tlsHosts.some(tlsHost => host.includes(tlsHost)); +} + +/** + * Log connection details (sanitized for security) + */ +function logConnectionDetails(host: string, port: number, tls: boolean): void { + if (!shouldLog) return; + + const tlsStatus = tls ? '๐Ÿ”’ TLS Enabled' : 'โš ๏ธ TLS Disabled'; + console.log(`๐Ÿ“ก Redis Connection: ${host}:${port} (${tlsStatus})`); + + if (process.env.DEBUG_REDIS === 'true') { + console.log(`๐Ÿ” Debug: TLS=${tls}, Host=${host}, Port=${port}`); + } +} + +/** + * Build Redis URL from environment variables + * Useful when socket-based config has TLS issues + */ +function buildRedisUrl(host: string, port: number, username: string, password: string, useTls: boolean): string { + const protocol = useTls ? 'rediss' : 'redis'; + // URL encode the password in case it contains special characters + const encodedPassword = encodeURIComponent(password); + const encodedUsername = encodeURIComponent(username); + const dbIndex = getRedisDbIndex(); + const dbPath = dbIndex !== null ? `/${dbIndex}` : ''; + return `${protocol}://${encodedUsername}:${encodedPassword}@${host}:${port}${dbPath}`; +} + +function getRedisDbIndex(): number | null { + if (!process.env.REDIS_DB) { + return null; + } + + const dbIndex = Number.parseInt(process.env.REDIS_DB, 10); + if (Number.isNaN(dbIndex)) { + console.warn('โš ๏ธ REDIS_DB must be a number (database index).'); + return null; + } + + return dbIndex; +} + +export async function getRedisClient(): Promise { + // Check if Redis is explicitly disabled + if (process.env.REDIS_ENABLED === 'false') { + if (shouldLog) console.log('โš ๏ธ Redis is disabled (REDIS_ENABLED=false)'); + throw new Error('Redis is disabled'); + } + + if (redisClient) { + return redisClient; + } + + // Configuration priority: + // 1. Socket-based config (REDIS_HOST, REDIS_PORT, REDIS_USERNAME, REDIS_PASSWORD) + // 2. URL-based config (REDIS_URL) + // 3. Default local Redis + + const shouldUseSocketConfig = + process.env.REDIS_HOST || + process.env.REDIS_PORT || + process.env.REDIS_USERNAME || + process.env.REDIS_PASSWORD; + + if (shouldUseSocketConfig) { + // Socket-based configuration (for Redis Cloud, AWS ElastiCache, etc.) + const host = process.env.REDIS_HOST || 'localhost'; + const port = parseInt(process.env.REDIS_PORT || '6379', 10); + const username = process.env.REDIS_USERNAME || 'default'; + const password = process.env.REDIS_PASSWORD || ''; + const tls = shouldEnableTLS(host); + + logConnectionDetails(host, port, tls); + + try { + // For Redis Cloud and managed services, URL-based config often works better for TLS + // The rediss:// protocol (with double 's') handles TLS automatically + if (tls && host.includes('cloud.redislabs.com')) { + if (shouldLog) console.log(`๐Ÿ“ก Using URL-based config for better TLS handling with Redis Cloud...`); + const redisUrl = buildRedisUrl(host, port, username, password, true); + redisClient = createClient({ + url: redisUrl, + socket: { + tls: true, + servername: host, // SNI so the CA validates the right cert + } + }); + } else { + // Standard socket-based config for local/non-TLS scenarios + const socketConfig: any = { + host, + port, + }; + + if (tls) { + socketConfig.tls = { + servername: host, // Server Name Indication (SNI) + }; + } + + redisClient = createClient({ + username, + password, + socket: socketConfig, + }); + } + } catch (error) { + if (shouldLog) console.error('โŒ Failed to create Redis client with socket config:', error); + throw error; + } + } else { + // URL-based configuration (simpler approach) + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + if (shouldLog) console.log(`๐Ÿ“ก Redis Connection: Using REDIS_URL environment variable`); + + try { + redisClient = createClient({ + url: redisUrl, + }); + } catch (error) { + if (shouldLog) console.error('โŒ Failed to create Redis client with URL config:', error); + throw error; + } + } + + redisClient.on('error', (err) => { + if (shouldLog) { + // Only log TLS errors once to avoid spam + if (err.message && err.message.includes('packet length too long')) { + console.error('โŒ Redis TLS Error: Connection configuration mismatch'); + console.error('๐Ÿ’ก Hint: Try disabling Redis or check your TLS settings'); + console.error(' Set REDIS_ENABLED=false in .env to disable Redis'); + } else { + console.error('โŒ Redis Client Error:', err.message || err); + } + if (process.env.DEBUG_REDIS === 'true') { + console.error('Full error:', err); + } + } + }); + redisClient.on('connect', () => shouldLog && console.log('โœ… Redis Client Connected')); + redisClient.on('ready', () => shouldLog && console.log('โœ… Redis Client Ready')); + redisClient.on('reconnecting', () => shouldLog && console.log('๐Ÿ”„ Redis Client Reconnecting...')); + + try { + await redisClient.connect(); + } catch (error) { + console.error('โŒ Failed to connect to Redis:', error); + if (error instanceof Error && error.message.includes('packet length too long')) { + console.error('๐Ÿ’ก Hint: This error suggests a TLS/SSL mismatch.'); + console.error(' Try one of these solutions:'); + console.error(' 1. Try URL-based config: Set REDIS_URL=rediss://user:pass@host:port'); + console.error(' 2. For Redis Cloud: Use the standard TLS port (usually 10434)'); + console.error(' 3. Check your Redis Cloud dashboard for the exact connection string'); + console.error(' 4. Ensure your password doesn\'t have special characters or URL-encode it'); + } + throw error; + } + + const dbIndex = getRedisDbIndex(); + if (dbIndex !== null) { + try { + await redisClient.select(dbIndex); + console.log(`โœ… Redis database selected: ${dbIndex}`); + } catch (error) { + console.error('โŒ Failed to select Redis database:', error); + throw error; + } + } else if (process.env.REDIS_DB_NAME) { + console.warn('โš ๏ธ Redis uses numeric database indexes; REDIS_DB_NAME is ignored.'); + console.warn('โš ๏ธ Set REDIS_DB to a number (e.g., 0) if you need database selection.'); + } + + return redisClient; +} + +export async function closeRedisClient(): Promise { + if (redisClient) { + await redisClient.quit(); + redisClient = null; + } +} + +export function isRedisConnected(): boolean { + return redisClient !== null && redisClient.isOpen; +} + +// Cache key prefix to avoid collisions +export const CACHE_KEYS = { + STATS: (username: string) => `stats:${username}`, + LANGUAGES: (username: string) => `languages:${username}`, + GRAPH: (username: string, params: string) => `graph:${username}:${params}`, + BADGE_VISITORS: (username: string) => `badge:visitors:${username}`, + BADGE_REPOSITORIES: (username: string) => `badge:repositories:${username}`, + BADGE_ORGANIZATION: (username: string) => `badge:organization:${username}`, + BADGE_LANGUAGES: (username: string) => `badge:languages:${username}`, + BADGE_FOLLOWERS: (username: string) => `badge:followers:${username}`, + BADGE_TOTAL_STARS: (username: string) => `badge:total_stars:${username}`, + BADGE_TOTAL_CONTRIBUTORS: (username: string) => `badge:total_contributors:${username}`, + BADGE_TOTAL_COMMITS: (username: string) => `badge:total_commits:${username}`, + BADGE_TOTAL_CODE_REVIEWS: (username: string) => `badge:total_code_reviews:${username}`, + BADGE_TOTAL_ISSUES: (username: string) => `badge:total_issues:${username}`, + BADGE_TOTAL_PULL_REQUESTS: (username: string) => `badge:total_pull_requests:${username}`, + BADGE_TOTAL_JOINED_YEARS: (username: string) => `badge:total_joined_years:${username}`, + // Project/Repository-specific badge cache keys + BADGE_REPO_STARS: (owner: string, repo: string) => `badge:repo_stars:${owner}:${repo}`, + BADGE_REPO_FORKS: (owner: string, repo: string) => `badge:repo_forks:${owner}:${repo}`, + BADGE_REPO_WATCHERS: (owner: string, repo: string) => `badge:repo_watchers:${owner}:${repo}`, + BADGE_REPO_ISSUES: (owner: string, repo: string) => `badge:repo_issues:${owner}:${repo}`, + BADGE_REPO_PRS: (owner: string, repo: string) => `badge:repo_prs:${owner}:${repo}`, + BADGE_REPO_CONTRIBUTORS: (owner: string, repo: string) => `badge:repo_contributors:${owner}:${repo}`, + BADGE_REPO_SIZE: (owner: string, repo: string) => `badge:repo_size:${owner}:${repo}`, + USER_DATA: (username: string) => `user:${username}`, +}; + +// Default cache TTLs (in seconds) +export const DEFAULT_TTL = { + STATS: 7200, // 2 hours (increased from 1 hour for better hit rate) + LANGUAGES: 7200, // 2 hours (increased from 1 hour) + GRAPH: 3600, // 1 hour (increased from 30 minutes) + BADGE: 3600, // 1 hour (increased from 30 minutes) + USER_DATA: 7200, // 2 hours (increased from 1 hour) +}; + +/** + * Get badge cache key generator by badge type + * @param badgeType - The badge type (e.g., 'VISITORS', 'FOLLOWERS', 'TOTAL_STARS') + * @returns Cache key generator function or null if invalid type + */ +export function getBadgeCacheKey(badgeType: string): ((username: string) => string) | null { + const key = `BADGE_${badgeType}` as const; + const badgeKeys: Record string> = { + 'BADGE_VISITORS': CACHE_KEYS.BADGE_VISITORS, + 'BADGE_REPOSITORIES': CACHE_KEYS.BADGE_REPOSITORIES, + 'BADGE_ORGANIZATION': CACHE_KEYS.BADGE_ORGANIZATION, + 'BADGE_LANGUAGES': CACHE_KEYS.BADGE_LANGUAGES, + 'BADGE_FOLLOWERS': CACHE_KEYS.BADGE_FOLLOWERS, + 'BADGE_TOTAL_STARS': CACHE_KEYS.BADGE_TOTAL_STARS, + 'BADGE_TOTAL_CONTRIBUTORS': CACHE_KEYS.BADGE_TOTAL_CONTRIBUTORS, + 'BADGE_TOTAL_COMMITS': CACHE_KEYS.BADGE_TOTAL_COMMITS, + 'BADGE_TOTAL_CODE_REVIEWS': CACHE_KEYS.BADGE_TOTAL_CODE_REVIEWS, + 'BADGE_TOTAL_ISSUES': CACHE_KEYS.BADGE_TOTAL_ISSUES, + 'BADGE_TOTAL_PULL_REQUESTS': CACHE_KEYS.BADGE_TOTAL_PULL_REQUESTS, + 'BADGE_TOTAL_JOINED_YEARS': CACHE_KEYS.BADGE_TOTAL_JOINED_YEARS, + }; + + return badgeKeys[key] || null; +} diff --git a/src/shared/utils/response-cache.ts b/src/shared/utils/response-cache.ts new file mode 100644 index 0000000..106eb98 --- /dev/null +++ b/src/shared/utils/response-cache.ts @@ -0,0 +1,44 @@ +/** + * Bounded in-memory response cache used by stats/badges/graphs/languages + * services. Wraps `LRUCache` so the eviction policy (capacity + TTL) lives + * in one place; a plain unbounded `Map` here is a memory-DoS vector because + * cache keys are derived from user-controlled query strings. + */ + +import { LRUCache } from 'lru-cache'; + +export interface ResponseCacheEntry { + data: string; + timestamp: number; +} + +/** Structural interface that both LRUCache and Map satisfy for + * the subset of methods our services actually call. Tests can pass a plain + * Map; production wires LRUCache. `.set()` returns `unknown` because Map + * and LRUCache return different chainable types. */ +export interface ResponseCache { + get(key: string): V | undefined; + set(key: string, value: V): unknown; + has(key: string): boolean; + delete(key: string): boolean; + clear(): void; +} + +const DEFAULT_MAX_ITEMS = 10_000; + +/** + * Create a bounded response cache. Entries older than `ttlMs` are treated as + * expired; once `max` items are stored, least-recently-used entries are + * evicted to make room. + */ +export function createResponseCache( + ttlMs: number, + max: number = DEFAULT_MAX_ITEMS, +): LRUCache { + return new LRUCache({ + max, + ttl: ttlMs, + // Update recency on read so hot entries stick around under pressure. + updateAgeOnGet: true, + }); +} diff --git a/src/utils/sidebar.ts b/src/shared/utils/sidebar.ts similarity index 97% rename from src/utils/sidebar.ts rename to src/shared/utils/sidebar.ts index b575a53..b5ab531 100644 --- a/src/utils/sidebar.ts +++ b/src/shared/utils/sidebar.ts @@ -1,143 +1,143 @@ -export class SidebarManager { - private leftSidebar: HTMLElement; - private rightSidebar: HTMLElement; - private triggerButtons: NodeListOf; - private content: HTMLElement; - private readonly SMALL_SCREEN_BREAKPOINT = 64 * 16; // 64rem = 1024px - private resizeTimeout: NodeJS.Timeout | null = null; - - constructor() { - const left = document.querySelector('#leftSidebar'); - const right = document.querySelector('#rightSidebar'); - const buttons = document.querySelectorAll('[data-trigger]'); - const cont = document.querySelector('[data-content]'); - - if (!left || !right || !buttons || !cont) { - throw new Error('Required sidebar elements not found in DOM'); - } - - this.leftSidebar = left as HTMLElement; - this.rightSidebar = right as HTMLElement; - this.triggerButtons = buttons as NodeListOf; - this.content = cont as HTMLElement; - } - - private updateContentWidth(): void { - const isSmallScreen = window.innerWidth < this.SMALL_SCREEN_BREAKPOINT; - - if (isSmallScreen) { - // On small screens, sidebars use fixed positioning with transforms - // Content takes full width - this.content.style.width = '100%'; - } else { - // On large screens, use width-based calculation - const leftCollapsed = this.leftSidebar.classList.contains('collapsed'); - const rightCollapsed = this.rightSidebar.classList.contains('collapsed'); - - let widthCalc = '100%'; - let subtractRem = 33; - // Default: 15rem left + 15rem right + 3rem gap - - if (leftCollapsed) subtractRem -= 16.5; - if (rightCollapsed) subtractRem -= 16.5; - - if (subtractRem > 0) { - widthCalc = `calc(100% - ${subtractRem}rem)`; - } - - this.content.style.width = widthCalc; - } - } - - private updateButtonPosition(target: string): void { - const isSmallScreen = window.innerWidth < this.SMALL_SCREEN_BREAKPOINT; - const button = document.querySelector(`[data-trigger="${target}"]`) as HTMLElement; - - if (!button) return; - - if (target === 'leftSidebar') { - const isCollapsed = this.leftSidebar.classList.contains('collapsed'); - button.classList.remove('left-10', 'left-16', 'left-[12.55rem]'); - - if (isSmallScreen) { - button.classList.add('left-10'); - } else { - button.classList.add(isCollapsed ? 'left-16' : 'left-[12.55rem]'); - } - } else if (target === 'rightSidebar') { - const isCollapsed = this.rightSidebar.classList.contains('collapsed'); - button.classList.remove('right-6', 'right-9', 'right-16'); - - if (isSmallScreen) { - button.classList.add('right-16'); - } else { - button.classList.add(isCollapsed ? 'right-16' : 'right-9'); - } - } - } - - private toggleSidebar(target: string): void { - const isSmallScreen = window.innerWidth < this.SMALL_SCREEN_BREAKPOINT; - const sidebar = target === 'leftSidebar' ? this.leftSidebar : this.rightSidebar; - const isCollapsed = sidebar.classList.contains('collapsed'); - - if (isCollapsed) { - sidebar.classList.remove('collapsed'); - setTimeout(() => { - sidebar.classList.remove('overflow-hidden'); - }, 300); - } else { - sidebar.classList.add('overflow-hidden', 'collapsed'); - } - - if (!isSmallScreen) { - this.updateButtonPosition(target); - this.updateContentWidth(); - } - } - - private handleSmallScreen(): void { - const isSmallScreen = window.innerWidth < this.SMALL_SCREEN_BREAKPOINT; - - if (isSmallScreen) { - // On small screens, collapse both sidebars by default - this.leftSidebar.classList.add('collapsed', 'overflow-hidden'); - this.rightSidebar.classList.add('collapsed', 'overflow-hidden'); - } else { - // On large screens, expand both sidebars - this.leftSidebar.classList.remove('collapsed', 'overflow-hidden'); - this.rightSidebar.classList.remove('collapsed', 'overflow-hidden'); - } - - // Update button positions for current screen size - this.updateButtonPosition('leftSidebar'); - this.updateButtonPosition('rightSidebar'); - - this.updateContentWidth(); - } - - private attachEventListeners(): void { - this.triggerButtons.forEach(button => { - button.addEventListener('click', (e) => { - const target = button.getAttribute('data-trigger'); - if (target) { - this.toggleSidebar(target); - } - }); - }); - - window.addEventListener('resize', () => { - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - } - this.resizeTimeout = setTimeout(() => { - this.handleSmallScreen(); - }, 250); - }); - } - - public init(): void { - this.handleSmallScreen(); - this.attachEventListeners(); - } +export class SidebarManager { + private leftSidebar: HTMLElement; + private rightSidebar: HTMLElement; + private triggerButtons: NodeListOf; + private content: HTMLElement; + private readonly SMALL_SCREEN_BREAKPOINT = 64 * 16; // 64rem = 1024px + private resizeTimeout: NodeJS.Timeout | null = null; + + constructor() { + const left = document.querySelector('#leftSidebar'); + const right = document.querySelector('#rightSidebar'); + const buttons = document.querySelectorAll('[data-trigger]'); + const cont = document.querySelector('[data-content]'); + + if (!left || !right || !buttons || !cont) { + throw new Error('Required sidebar elements not found in DOM'); + } + + this.leftSidebar = left as HTMLElement; + this.rightSidebar = right as HTMLElement; + this.triggerButtons = buttons as NodeListOf; + this.content = cont as HTMLElement; + } + + private updateContentWidth(): void { + const isSmallScreen = window.innerWidth < this.SMALL_SCREEN_BREAKPOINT; + + if (isSmallScreen) { + // On small screens, sidebars use fixed positioning with transforms + // Content takes full width + this.content.style.width = '100%'; + } else { + // On large screens, use width-based calculation + const leftCollapsed = this.leftSidebar.classList.contains('collapsed'); + const rightCollapsed = this.rightSidebar.classList.contains('collapsed'); + + let widthCalc = '100%'; + let subtractRem = 33; + // Default: 15rem left + 15rem right + 3rem gap + + if (leftCollapsed) subtractRem -= 16.5; + if (rightCollapsed) subtractRem -= 16.5; + + if (subtractRem > 0) { + widthCalc = `calc(100% - ${subtractRem}rem)`; + } + + this.content.style.width = widthCalc; + } + } + + private updateButtonPosition(target: string): void { + const isSmallScreen = window.innerWidth < this.SMALL_SCREEN_BREAKPOINT; + const button = document.querySelector(`[data-trigger="${target}"]`) as HTMLElement; + + if (!button) return; + + if (target === 'leftSidebar') { + const isCollapsed = this.leftSidebar.classList.contains('collapsed'); + button.classList.remove('left-10', 'left-16', 'left-[12.55rem]'); + + if (isSmallScreen) { + button.classList.add('left-10'); + } else { + button.classList.add(isCollapsed ? 'left-16' : 'left-[12.55rem]'); + } + } else if (target === 'rightSidebar') { + const isCollapsed = this.rightSidebar.classList.contains('collapsed'); + button.classList.remove('right-6', 'right-9', 'right-16'); + + if (isSmallScreen) { + button.classList.add('right-16'); + } else { + button.classList.add(isCollapsed ? 'right-16' : 'right-9'); + } + } + } + + private toggleSidebar(target: string): void { + const isSmallScreen = window.innerWidth < this.SMALL_SCREEN_BREAKPOINT; + const sidebar = target === 'leftSidebar' ? this.leftSidebar : this.rightSidebar; + const isCollapsed = sidebar.classList.contains('collapsed'); + + if (isCollapsed) { + sidebar.classList.remove('collapsed'); + setTimeout(() => { + sidebar.classList.remove('overflow-hidden'); + }, 300); + } else { + sidebar.classList.add('overflow-hidden', 'collapsed'); + } + + if (!isSmallScreen) { + this.updateButtonPosition(target); + this.updateContentWidth(); + } + } + + private handleSmallScreen(): void { + const isSmallScreen = window.innerWidth < this.SMALL_SCREEN_BREAKPOINT; + + if (isSmallScreen) { + // On small screens, collapse both sidebars by default + this.leftSidebar.classList.add('collapsed', 'overflow-hidden'); + this.rightSidebar.classList.add('collapsed', 'overflow-hidden'); + } else { + // On large screens, expand both sidebars + this.leftSidebar.classList.remove('collapsed', 'overflow-hidden'); + this.rightSidebar.classList.remove('collapsed', 'overflow-hidden'); + } + + // Update button positions for current screen size + this.updateButtonPosition('leftSidebar'); + this.updateButtonPosition('rightSidebar'); + + this.updateContentWidth(); + } + + private attachEventListeners(): void { + this.triggerButtons.forEach(button => { + button.addEventListener('click', (e) => { + const target = button.getAttribute('data-trigger'); + if (target) { + this.toggleSidebar(target); + } + }); + }); + + window.addEventListener('resize', () => { + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + } + this.resizeTimeout = setTimeout(() => { + this.handleSmallScreen(); + }, 250); + }); + } + + public init(): void { + this.handleSmallScreen(); + this.attachEventListeners(); + } } \ No newline at end of file diff --git a/src/shared/utils/stats-cleanup.ts b/src/shared/utils/stats-cleanup.ts new file mode 100644 index 0000000..2540076 --- /dev/null +++ b/src/shared/utils/stats-cleanup.ts @@ -0,0 +1,59 @@ +/** + * Periodic prune job for the `stats_requests` table (H7). + * + * Rows are fire-and-forget writes from the tracker middleware; without a + * cleanup they grow forever. This module deletes rows older than the + * configured retention on a repeating interval and returns a stop handle + * so the server can shut the interval down gracefully. + */ + +import { lt } from 'drizzle-orm'; +import { db } from '../../db/index.js'; +import { statsRequests } from '../../db/schema.js'; +import { createLogger } from '../logs/logger.js'; + +const logger = createLogger({ module: 'stats-cleanup' }); + +const HOUR_MS = 60 * 60 * 1000; + +export interface StatsCleanupOptions { + retentionDays: number; + intervalHours: number; +} + +/** + * Delete rows older than `retentionDays`. Runs an immediate pass and then + * a repeating one every `intervalHours`. Returns a stop function. + * + * The interval is `.unref()`-ed so it never keeps Node alive on its own. + */ +export function scheduleStatsCleanup(opts: StatsCleanupOptions): () => void { + const retentionMs = opts.retentionDays * 24 * HOUR_MS; + const intervalMs = opts.intervalHours * HOUR_MS; + + const run = async () => { + const cutoff = Date.now() - retentionMs; + try { + const result = await db.delete(statsRequests).where(lt(statsRequests.created_at, cutoff)); + // Drizzle's better-sqlite3 driver exposes `changes` on the result; + // it's undefined on other drivers, so log conditionally. + const changes = (result as { changes?: number }).changes; + logger.info('Pruned stats_requests', { + cutoffIso: new Date(cutoff).toISOString(), + changes: changes ?? 'unknown', + }); + } catch (err) { + logger.warn('stats_requests prune failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + // Kick off the first pass on next tick so startup isn't blocked. + setImmediate(run); + + const handle = setInterval(run, intervalMs); + handle.unref(); + + return () => clearInterval(handle); +} diff --git a/src/shared/utils/svg-safe.ts b/src/shared/utils/svg-safe.ts new file mode 100644 index 0000000..89504b9 --- /dev/null +++ b/src/shared/utils/svg-safe.ts @@ -0,0 +1,42 @@ +/** + * SVG-injection defences shared by badge/card/graph renderers. + * + * Two independent hardening steps: + * 1. `svgEscape` โ€” escapes text/attribute interpolations before they hit SVG output. + * 2. `normalizeHexColor` โ€” validates a user-supplied color at the controller + * boundary. Only pure hex is accepted; anything else (named colors, rgb(), + * CSS expressions) is rejected so it can never reach a `fill="โ€ฆ"` attribute. + */ + +const HEX_COLOR = /^#?[0-9a-fA-F]{3,8}$/; + +/** + * Escape the five XML characters that can break out of a text node or attribute + * value. Escapes `& < > " '` so an interpolated value like + * `` becomes inert text. + */ +export function svgEscape(value: string): string { + return value.replace(/[&<>"']/g, (ch) => { + switch (ch) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case "'": return '''; + default: return ch; + } + }); +} + +/** + * Accept `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` or the same without leading + * `#`. Returns the canonical `#โ€ฆ` form, or `null` if invalid. + */ +export function normalizeHexColor(value: string): string | null { + if (!HEX_COLOR.test(value)) return null; + const hex = value.startsWith('#') ? value.slice(1) : value; + if (hex.length !== 3 && hex.length !== 4 && hex.length !== 6 && hex.length !== 8) { + return null; + } + return `#${hex}`; +} diff --git a/src/utils/themes.ts b/src/shared/utils/themes.ts similarity index 72% rename from src/utils/themes.ts rename to src/shared/utils/themes.ts index 7245c58..58e28c3 100644 --- a/src/utils/themes.ts +++ b/src/shared/utils/themes.ts @@ -1,7 +1,8 @@ -import { Theme, BadgeTheme } from '../types.js'; +import { Theme } from '../types/themes.type.js'; import { baseThemes } from './themes/base.js'; import { graphThemes } from './themes/graph.js'; import { badgeThemes } from './themes/badge.js'; +import { BadgeTheme } from '../types/badge.types.js'; const defaultFontName = 'Orbitron'; const defaultFontFamily = `'${defaultFontName}', 'Ubuntu', 'sans-serif'`; @@ -47,6 +48,29 @@ function resolveBadgeThemeName(name: string): string { return resolved ?? 'default'; } +/** Does a user-supplied theme name resolve to a registered theme? Uses the + * same fuzzy matching rules as `resolveThemeName`. */ +export function isKnownTheme(name: string): boolean { + return themes[name] !== undefined || themeIndex.has(normalizeKey(name)); +} + +/** Does a user-supplied badge theme name resolve to a registered badge theme? */ +export function isKnownBadgeTheme(name: string): boolean { + return badgeThemes[name] !== undefined || badgeThemeIndex.has(normalizeKey(name)); +} + +/** Public wrapper for `resolveThemeName` โ€” canonicalises aliases like + * `Ocean` / `tokyo_night` to their storage key. Falls back to `'default'` + * for unknown names so callers never end up with an untyped string. */ +export function normalizeThemeName(name: string): string { + return resolveThemeName(name); +} + +/** Public wrapper for `resolveBadgeThemeName`. */ +export function normalizeBadgeThemeName(name: string): string { + return resolveBadgeThemeName(name); +} + export function getTheme(themeName: string = 'default', customColors?: { bgColor?: string; borderColor?: string; diff --git a/src/utils/themes/badge.ts b/src/shared/utils/themes/badge.ts similarity index 96% rename from src/utils/themes/badge.ts rename to src/shared/utils/themes/badge.ts index d548578..08bb4f6 100644 --- a/src/utils/themes/badge.ts +++ b/src/shared/utils/themes/badge.ts @@ -1,4 +1,4 @@ -import { BadgeTheme } from '../../types.js'; +import { BadgeTheme } from "../../types/badge.types.js"; export const badgeThemes: { [key: string]: BadgeTheme } = { default: { diff --git a/src/utils/themes/base.ts b/src/shared/utils/themes/base.ts similarity index 95% rename from src/utils/themes/base.ts rename to src/shared/utils/themes/base.ts index 9bfb300..610dfeb 100644 --- a/src/utils/themes/base.ts +++ b/src/shared/utils/themes/base.ts @@ -1,414 +1,414 @@ -import { Theme } from '../../types.js'; - -/** - * Classic / general-purpose themes. - * These work well across all card types: /stats, /languages, /graph. - */ -export const baseThemes: { [key: string]: Theme } = { - default: { - titleColor: '#00c8ff', - textColor: '#ffffff', - iconColor: '#00c8ff', - bgColor: '#0a0e27', - borderColor: '#888888', - }, - dark: { - titleColor: '#70a5fd', - textColor: '#ffffff', - iconColor: '#79ff97', - bgColor: '#151515', - borderColor: '#1f1f1f', - }, - radical: { - titleColor: '#fe428e', - textColor: '#a9fef7', - iconColor: '#f8d847', - bgColor: '#141321', - borderColor: '#383838', - }, - merko: { - titleColor: '#abd200', - textColor: '#68b587', - iconColor: '#b7d364', - bgColor: '#0a0f0b', - borderColor: '#1f1f1f', - }, - gruvbox: { - titleColor: '#fabd2f', - textColor: '#8ec07c', - iconColor: '#fe8019', - bgColor: '#282828', - borderColor: '#3c3836', - }, - tokyonight: { - titleColor: '#70a5fd', - textColor: '#38bdae', - iconColor: '#bf91f3', - bgColor: '#1a1b27', - borderColor: '#1f2335', - }, - onedark: { - titleColor: '#e4bf7a', - textColor: '#df6d74', - iconColor: '#8eb573', - bgColor: '#282c34', - borderColor: '#3e4451', - }, - cobalt: { - titleColor: '#e683d9', - textColor: '#ffffff', - iconColor: '#75eeb2', - bgColor: '#193549', - borderColor: '#1f456e', - }, - synthwave: { - titleColor: '#e2e9ec', - textColor: '#e5289e', - iconColor: '#ef8539', - bgColor: '#2b213a', - borderColor: '#6b4d8a', - }, - highcontrast: { - titleColor: '#e7f216', - textColor: '#ffffff', - iconColor: '#00ffff', - bgColor: '#000000', - borderColor: '#ffffff', - }, - dracula: { - titleColor: '#ff6e96', - textColor: '#f8f8f2', - iconColor: '#79dafa', - bgColor: '#282a36', - borderColor: '#44475a', - }, - prussian: { - titleColor: '#bddfff', - textColor: '#6e9ed8', - iconColor: '#38a0ff', - bgColor: '#172f45', - borderColor: '#255078', - }, - monokai: { - titleColor: '#eb1f6a', - textColor: '#f1f1eb', - iconColor: '#e28905', - bgColor: '#272822', - borderColor: '#4e4e43', - }, - vue: { - titleColor: '#41b883', - textColor: '#41b883', - iconColor: '#41b883', - bgColor: '#273849', - borderColor: '#415367', - }, - 'vue-dark': { - titleColor: '#41b883', - textColor: '#fffefe', - iconColor: '#41b883', - bgColor: '#0d1117', - borderColor: '#1f2328', - }, - 'shades-of-purple': { - titleColor: '#fad000', - textColor: '#a599e9', - iconColor: '#b362ff', - bgColor: '#2d2b55', - borderColor: '#4d21fc', - }, - nightowl: { - titleColor: '#c792ea', - textColor: '#7fdbca', - iconColor: '#ffeb95', - bgColor: '#011627', - borderColor: '#1d3b53', - }, - 'buefy-dark': { - titleColor: '#7957d5', - textColor: '#c3c3c3', - iconColor: '#ff3860', - bgColor: '#1d1e22', - borderColor: '#363636', - }, - 'blue-green': { - titleColor: '#2f97c1', - textColor: '#0cf57d', - iconColor: '#00d8ff', - bgColor: '#040f0f', - borderColor: '#0f4c59', - }, - algolia: { - titleColor: '#00aeff', - textColor: '#ffffff', - iconColor: '#00aeff', - bgColor: '#050f2c', - borderColor: '#172a53', - }, - 'great-gatsby': { - titleColor: '#ffa726', - textColor: '#ffd95b', - iconColor: '#ffa726', - bgColor: '#000000', - borderColor: '#684c8d', - }, - darcula: { - titleColor: '#ba5dd0', - textColor: '#bcbec4', - iconColor: '#6b9cef', - bgColor: '#242424', - borderColor: '#323232', - }, - bear: { - titleColor: '#e03c8a', - textColor: '#bcb28d', - iconColor: '#00aeff', - bgColor: '#1f2023', - borderColor: '#36373a', - }, - 'solarized-dark': { - titleColor: '#268bd2', - textColor: '#859900', - iconColor: '#b58900', - bgColor: '#002b36', - borderColor: '#073642', - }, - 'solarized-light': { - titleColor: '#268bd2', - textColor: '#859900', - iconColor: '#b58900', - bgColor: '#fdf6e3', - borderColor: '#eee8d5', - }, - 'chartreuse-dark': { - titleColor: '#7fff00', - textColor: '#fff', - iconColor: '#00aeff', - bgColor: '#000', - borderColor: '#1f1f1f', - }, - nord: { - titleColor: '#81a1c1', - textColor: '#d8dee9', - iconColor: '#88c0d0', - bgColor: '#2e3440', - borderColor: '#3b4252', - }, - gotham: { - titleColor: '#2aa889', - textColor: '#99d1ce', - iconColor: '#599cab', - bgColor: '#0c1014', - borderColor: '#1d252d', - }, - 'material-palenight': { - titleColor: '#c792ea', - textColor: '#a6accd', - iconColor: '#89ddff', - bgColor: '#292d3e', - borderColor: '#444267', - }, - graywhite: { - titleColor: '#24292e', - textColor: '#24292e', - iconColor: '#24292e', - bgColor: '#ffffff', - borderColor: '#e1e4e8', - }, - 'vision-friendly-dark': { - titleColor: '#ffb000', - textColor: '#ffffff', - iconColor: '#785ef0', - bgColor: '#000000', - borderColor: '#1f1f1f', - }, - 'ayu-mirage': { - titleColor: '#f4cd7c', - textColor: '#73d0ff', - iconColor: '#ffcc66', - bgColor: '#1f2430', - borderColor: '#2c3444', - }, - 'midnight-purple': { - titleColor: '#9745f5', - textColor: '#ffffff', - iconColor: '#8b7ec9', - bgColor: '#000000', - borderColor: '#1f1f1f', - }, - calm: { - titleColor: '#e07a5f', - textColor: '#ebcfb2', - iconColor: '#edae49', - bgColor: '#1a1a1a', - borderColor: '#2a2a2a', - }, - 'flag-india': { - titleColor: '#ff8f1c', - textColor: '#250E62', - iconColor: '#509E2F', - bgColor: '#ffffff', - borderColor: '#ff8f1c', - }, - omni: { - titleColor: '#FF79C6', - textColor: '#E1E1E6', - iconColor: '#E89E64', - bgColor: '#191622', - borderColor: '#44475a', - }, - react: { - titleColor: '#61dafb', - textColor: '#ffffff', - iconColor: '#61dafb', - bgColor: '#20232a', - borderColor: '#2d3748', - }, - jolly: { - titleColor: '#ff64da', - textColor: '#ffffff', - iconColor: '#a960ff', - bgColor: '#291B3E', - borderColor: '#461e5b', - }, - maroongold: { - titleColor: '#F7EF8A', - textColor: '#E0AA3E', - iconColor: '#F7EF8A', - bgColor: '#260000', - borderColor: '#3d0000', - }, - yeblu: { - titleColor: '#ffff00', - textColor: '#ffffff', - iconColor: '#0000ff', - bgColor: '#002046', - borderColor: '#19558c', - }, - blueberry: { - titleColor: '#82aaff', - textColor: '#e4f0fb', - iconColor: '#89ddff', - bgColor: '#242938', - borderColor: '#3e4b66', - }, - slateorange: { - titleColor: '#e2a75a', - textColor: '#ffffff', - iconColor: '#e2a75a', - bgColor: '#36393f', - borderColor: '#4a4d52', - }, - kacho_ga: { - titleColor: '#bf4a3f', - textColor: '#d9c8a9', - iconColor: '#a64833', - bgColor: '#402b23', - borderColor: '#ae9a87', - }, - outrun: { - titleColor: '#ffcc00', - textColor: '#8b9fde', - iconColor: '#ff1aff', - bgColor: '#141439', - borderColor: '#1f1f5e', - }, - ocean_dark: { - titleColor: '#8957B2', - textColor: '#92D534', - iconColor: '#519259', - bgColor: '#151a28', - borderColor: '#1d2633', - }, - city_lights: { - titleColor: '#5D8CB3', - textColor: '#B7C9D3', - iconColor: '#4BC96B', - bgColor: '#1D252C', - borderColor: '#354856', - }, - github_dark: { - titleColor: '#58a6ff', - textColor: '#c9d1d9', - iconColor: '#79c0ff', - bgColor: '#0d1117', - borderColor: '#30363d', - }, - discord_old_blurple: { - titleColor: '#7289da', - textColor: '#ffffff', - iconColor: '#7289da', - bgColor: '#2c2f33', - borderColor: '#23272a', - }, - aura_dark: { - titleColor: '#a277ff', - textColor: '#edecee', - iconColor: '#61ffca', - bgColor: '#15141b', - borderColor: '#3d375e', - }, - panda: { - titleColor: '#19f9d8', - textColor: '#FF75B5', - iconColor: '#ffb86c', - bgColor: '#31353a', - borderColor: '#4a4d52', - }, - noctis_minimus: { - titleColor: '#d3b692', - textColor: '#c5cdd3', - iconColor: '#7aa5ff', - bgColor: '#1b2932', - borderColor: '#273645', - }, - cobalt2: { - titleColor: '#ffc600', - textColor: '#ffffff', - iconColor: '#80ffbb', - bgColor: '#193549', - borderColor: '#285580', - }, - swift: { - titleColor: '#f05237', - textColor: '#ffffff', - iconColor: '#f05237', - bgColor: '#232323', - borderColor: '#3a3a3a', - }, - aura: { - titleColor: '#a277ff', - textColor: '#edecee', - iconColor: '#61ffca', - bgColor: '#15141b', - borderColor: '#3d375e', - }, - apprentice: { - titleColor: '#ffffaf', - textColor: '#bcbcbc', - iconColor: '#87afd7', - bgColor: '#262626', - borderColor: '#3a3a3a', - }, - moltack: { - titleColor: '#86092C', - textColor: '#574038', - iconColor: '#F19D38', - bgColor: '#F5E1C0', - borderColor: '#E4D4B5', - }, - codeSTACKr: { - titleColor: '#ff652f', - textColor: '#ffffff', - iconColor: '#ff652f', - bgColor: '#09131B', - borderColor: '#0D1620', - }, - rose_pine: { - titleColor: '#9ccfd8', - textColor: '#e0def4', - iconColor: '#c4a7e7', - bgColor: '#191724', - borderColor: '#26233a', - }, -}; +import { Theme } from "../../types/themes.type.js"; + +/** + * Classic / general-purpose themes. + * These work well across all card types: /stats, /languages, /graph. + */ +export const baseThemes: { [key: string]: Theme } = { + default: { + titleColor: '#00c8ff', + textColor: '#ffffff', + iconColor: '#00c8ff', + bgColor: '#0a0e27', + borderColor: '#888888', + }, + dark: { + titleColor: '#70a5fd', + textColor: '#ffffff', + iconColor: '#79ff97', + bgColor: '#151515', + borderColor: '#1f1f1f', + }, + radical: { + titleColor: '#fe428e', + textColor: '#a9fef7', + iconColor: '#f8d847', + bgColor: '#141321', + borderColor: '#383838', + }, + merko: { + titleColor: '#abd200', + textColor: '#68b587', + iconColor: '#b7d364', + bgColor: '#0a0f0b', + borderColor: '#1f1f1f', + }, + gruvbox: { + titleColor: '#fabd2f', + textColor: '#8ec07c', + iconColor: '#fe8019', + bgColor: '#282828', + borderColor: '#3c3836', + }, + tokyonight: { + titleColor: '#70a5fd', + textColor: '#38bdae', + iconColor: '#bf91f3', + bgColor: '#1a1b27', + borderColor: '#1f2335', + }, + onedark: { + titleColor: '#e4bf7a', + textColor: '#df6d74', + iconColor: '#8eb573', + bgColor: '#282c34', + borderColor: '#3e4451', + }, + cobalt: { + titleColor: '#e683d9', + textColor: '#ffffff', + iconColor: '#75eeb2', + bgColor: '#193549', + borderColor: '#1f456e', + }, + synthwave: { + titleColor: '#e2e9ec', + textColor: '#e5289e', + iconColor: '#ef8539', + bgColor: '#2b213a', + borderColor: '#6b4d8a', + }, + highcontrast: { + titleColor: '#e7f216', + textColor: '#ffffff', + iconColor: '#00ffff', + bgColor: '#000000', + borderColor: '#ffffff', + }, + dracula: { + titleColor: '#ff6e96', + textColor: '#f8f8f2', + iconColor: '#79dafa', + bgColor: '#282a36', + borderColor: '#44475a', + }, + prussian: { + titleColor: '#bddfff', + textColor: '#6e9ed8', + iconColor: '#38a0ff', + bgColor: '#172f45', + borderColor: '#255078', + }, + monokai: { + titleColor: '#eb1f6a', + textColor: '#f1f1eb', + iconColor: '#e28905', + bgColor: '#272822', + borderColor: '#4e4e43', + }, + vue: { + titleColor: '#41b883', + textColor: '#41b883', + iconColor: '#41b883', + bgColor: '#273849', + borderColor: '#415367', + }, + 'vue-dark': { + titleColor: '#41b883', + textColor: '#fffefe', + iconColor: '#41b883', + bgColor: '#0d1117', + borderColor: '#1f2328', + }, + 'shades-of-purple': { + titleColor: '#fad000', + textColor: '#a599e9', + iconColor: '#b362ff', + bgColor: '#2d2b55', + borderColor: '#4d21fc', + }, + nightowl: { + titleColor: '#c792ea', + textColor: '#7fdbca', + iconColor: '#ffeb95', + bgColor: '#011627', + borderColor: '#1d3b53', + }, + 'buefy-dark': { + titleColor: '#7957d5', + textColor: '#c3c3c3', + iconColor: '#ff3860', + bgColor: '#1d1e22', + borderColor: '#363636', + }, + 'blue-green': { + titleColor: '#2f97c1', + textColor: '#0cf57d', + iconColor: '#00d8ff', + bgColor: '#040f0f', + borderColor: '#0f4c59', + }, + algolia: { + titleColor: '#00aeff', + textColor: '#ffffff', + iconColor: '#00aeff', + bgColor: '#050f2c', + borderColor: '#172a53', + }, + 'great-gatsby': { + titleColor: '#ffa726', + textColor: '#ffd95b', + iconColor: '#ffa726', + bgColor: '#000000', + borderColor: '#684c8d', + }, + darcula: { + titleColor: '#ba5dd0', + textColor: '#bcbec4', + iconColor: '#6b9cef', + bgColor: '#242424', + borderColor: '#323232', + }, + bear: { + titleColor: '#e03c8a', + textColor: '#bcb28d', + iconColor: '#00aeff', + bgColor: '#1f2023', + borderColor: '#36373a', + }, + 'solarized-dark': { + titleColor: '#268bd2', + textColor: '#859900', + iconColor: '#b58900', + bgColor: '#002b36', + borderColor: '#073642', + }, + 'solarized-light': { + titleColor: '#268bd2', + textColor: '#859900', + iconColor: '#b58900', + bgColor: '#fdf6e3', + borderColor: '#eee8d5', + }, + 'chartreuse-dark': { + titleColor: '#7fff00', + textColor: '#fff', + iconColor: '#00aeff', + bgColor: '#000', + borderColor: '#1f1f1f', + }, + nord: { + titleColor: '#81a1c1', + textColor: '#d8dee9', + iconColor: '#88c0d0', + bgColor: '#2e3440', + borderColor: '#3b4252', + }, + gotham: { + titleColor: '#2aa889', + textColor: '#99d1ce', + iconColor: '#599cab', + bgColor: '#0c1014', + borderColor: '#1d252d', + }, + 'material-palenight': { + titleColor: '#c792ea', + textColor: '#a6accd', + iconColor: '#89ddff', + bgColor: '#292d3e', + borderColor: '#444267', + }, + graywhite: { + titleColor: '#24292e', + textColor: '#24292e', + iconColor: '#24292e', + bgColor: '#ffffff', + borderColor: '#e1e4e8', + }, + 'vision-friendly-dark': { + titleColor: '#ffb000', + textColor: '#ffffff', + iconColor: '#785ef0', + bgColor: '#000000', + borderColor: '#1f1f1f', + }, + 'ayu-mirage': { + titleColor: '#f4cd7c', + textColor: '#73d0ff', + iconColor: '#ffcc66', + bgColor: '#1f2430', + borderColor: '#2c3444', + }, + 'midnight-purple': { + titleColor: '#9745f5', + textColor: '#ffffff', + iconColor: '#8b7ec9', + bgColor: '#000000', + borderColor: '#1f1f1f', + }, + calm: { + titleColor: '#e07a5f', + textColor: '#ebcfb2', + iconColor: '#edae49', + bgColor: '#1a1a1a', + borderColor: '#2a2a2a', + }, + 'flag-india': { + titleColor: '#ff8f1c', + textColor: '#250E62', + iconColor: '#509E2F', + bgColor: '#ffffff', + borderColor: '#ff8f1c', + }, + omni: { + titleColor: '#FF79C6', + textColor: '#E1E1E6', + iconColor: '#E89E64', + bgColor: '#191622', + borderColor: '#44475a', + }, + react: { + titleColor: '#61dafb', + textColor: '#ffffff', + iconColor: '#61dafb', + bgColor: '#20232a', + borderColor: '#2d3748', + }, + jolly: { + titleColor: '#ff64da', + textColor: '#ffffff', + iconColor: '#a960ff', + bgColor: '#291B3E', + borderColor: '#461e5b', + }, + maroongold: { + titleColor: '#F7EF8A', + textColor: '#E0AA3E', + iconColor: '#F7EF8A', + bgColor: '#260000', + borderColor: '#3d0000', + }, + yeblu: { + titleColor: '#ffff00', + textColor: '#ffffff', + iconColor: '#0000ff', + bgColor: '#002046', + borderColor: '#19558c', + }, + blueberry: { + titleColor: '#82aaff', + textColor: '#e4f0fb', + iconColor: '#89ddff', + bgColor: '#242938', + borderColor: '#3e4b66', + }, + slateorange: { + titleColor: '#e2a75a', + textColor: '#ffffff', + iconColor: '#e2a75a', + bgColor: '#36393f', + borderColor: '#4a4d52', + }, + kacho_ga: { + titleColor: '#bf4a3f', + textColor: '#d9c8a9', + iconColor: '#a64833', + bgColor: '#402b23', + borderColor: '#ae9a87', + }, + outrun: { + titleColor: '#ffcc00', + textColor: '#8b9fde', + iconColor: '#ff1aff', + bgColor: '#141439', + borderColor: '#1f1f5e', + }, + ocean_dark: { + titleColor: '#8957B2', + textColor: '#92D534', + iconColor: '#519259', + bgColor: '#151a28', + borderColor: '#1d2633', + }, + city_lights: { + titleColor: '#5D8CB3', + textColor: '#B7C9D3', + iconColor: '#4BC96B', + bgColor: '#1D252C', + borderColor: '#354856', + }, + github_dark: { + titleColor: '#58a6ff', + textColor: '#c9d1d9', + iconColor: '#79c0ff', + bgColor: '#0d1117', + borderColor: '#30363d', + }, + discord_old_blurple: { + titleColor: '#7289da', + textColor: '#ffffff', + iconColor: '#7289da', + bgColor: '#2c2f33', + borderColor: '#23272a', + }, + aura_dark: { + titleColor: '#a277ff', + textColor: '#edecee', + iconColor: '#61ffca', + bgColor: '#15141b', + borderColor: '#3d375e', + }, + panda: { + titleColor: '#19f9d8', + textColor: '#FF75B5', + iconColor: '#ffb86c', + bgColor: '#31353a', + borderColor: '#4a4d52', + }, + noctis_minimus: { + titleColor: '#d3b692', + textColor: '#c5cdd3', + iconColor: '#7aa5ff', + bgColor: '#1b2932', + borderColor: '#273645', + }, + cobalt2: { + titleColor: '#ffc600', + textColor: '#ffffff', + iconColor: '#80ffbb', + bgColor: '#193549', + borderColor: '#285580', + }, + swift: { + titleColor: '#f05237', + textColor: '#ffffff', + iconColor: '#f05237', + bgColor: '#232323', + borderColor: '#3a3a3a', + }, + aura: { + titleColor: '#a277ff', + textColor: '#edecee', + iconColor: '#61ffca', + bgColor: '#15141b', + borderColor: '#3d375e', + }, + apprentice: { + titleColor: '#ffffaf', + textColor: '#bcbcbc', + iconColor: '#87afd7', + bgColor: '#262626', + borderColor: '#3a3a3a', + }, + moltack: { + titleColor: '#86092C', + textColor: '#574038', + iconColor: '#F19D38', + bgColor: '#F5E1C0', + borderColor: '#E4D4B5', + }, + codeSTACKr: { + titleColor: '#ff652f', + textColor: '#ffffff', + iconColor: '#ff652f', + bgColor: '#09131B', + borderColor: '#0D1620', + }, + rose_pine: { + titleColor: '#9ccfd8', + textColor: '#e0def4', + iconColor: '#c4a7e7', + bgColor: '#191724', + borderColor: '#26233a', + }, +}; diff --git a/src/utils/themes/graph.ts b/src/shared/utils/themes/graph.ts similarity index 95% rename from src/utils/themes/graph.ts rename to src/shared/utils/themes/graph.ts index 2384835..2c3af1f 100644 --- a/src/utils/themes/graph.ts +++ b/src/shared/utils/themes/graph.ts @@ -1,89 +1,89 @@ -import { Theme } from '../../types.js'; - -/** - * Graph-optimized themes for the /graph activity heatmap. - * - * These themes are tuned so that `iconColor` (the heatmap cell fill) is vivid - * and high-contrast against the near-black `bgColor`, making contribution - * density immediately readable. - * - * Key colour roles in the graph: - * titleColor โ†’ large heading text (username + year) - * textColor โ†’ subtitle, month labels, legend labels - * iconColor โ†’ heatmap cell fill (levels 1-4 derived from this) - * bgColor โ†’ canvas background (should be very dark) - * borderColor โ†’ background grid lines and divider - */ -export const graphThemes: { [key: string]: Theme } = { - /** Emerald green โ€” Northern Lights feel */ - aurora: { - titleColor: '#a8ffce', - textColor: '#c8ffd4', - iconColor: '#00e676', - bgColor: '#020c12', - borderColor: '#0a3026', - }, - - /** Pure terminal green on black โ€” classic hacker aesthetic */ - matrix: { - titleColor: '#00ff41', - textColor: '#39ff14', - iconColor: '#00cc33', - bgColor: '#000000', - borderColor: '#003300', - }, - - /** Red-orange fire gradient โ€” heat/intensity */ - inferno: { - titleColor: '#ff9a00', - textColor: '#ffcf77', - iconColor: '#ff4500', - bgColor: '#0d0200', - borderColor: '#3d0a00', - }, - - /** Deep cyan-blue water โ€” calm and readable */ - ocean: { - titleColor: '#00d4ff', - textColor: '#b2f0ff', - iconColor: '#0099cc', - bgColor: '#020d1a', - borderColor: '#0a2a40', - }, - - /** Magenta / purple โ€” cyberpunk neon */ - neon: { - titleColor: '#ff00cc', - textColor: '#ff99ee', - iconColor: '#cc00ff', - bgColor: '#0a000f', - borderColor: '#3d0050', - }, - - /** Amber / gold โ€” warm solar glow */ - solar: { - titleColor: '#ffd700', - textColor: '#ffe88a', - iconColor: '#f5a623', - bgColor: '#0d0900', - borderColor: '#3a2800', - }, - - /** Violet / purple โ€” deep-space galaxy */ - galaxy: { - titleColor: '#c084fc', - textColor: '#e9d5ff', - iconColor: '#8b5cf6', - bgColor: '#05020f', - borderColor: '#1e0a40', - }, - - /** GitHub native dark palette โ€” green contribution cells */ - 'github-dark': { - titleColor: '#58a6ff', - textColor: '#c9d1d9', - iconColor: '#39d353', - bgColor: '#0d1117', - borderColor: '#21262d', - }, -}; +import { Theme } from "../../types/themes.type.js"; + +/** + * Graph-optimized themes for the /graph activity heatmap. + * + * These themes are tuned so that `iconColor` (the heatmap cell fill) is vivid + * and high-contrast against the near-black `bgColor`, making contribution + * density immediately readable. + * + * Key colour roles in the graph: + * titleColor โ†’ large heading text (username + year) + * textColor โ†’ subtitle, month labels, legend labels + * iconColor โ†’ heatmap cell fill (levels 1-4 derived from this) + * bgColor โ†’ canvas background (should be very dark) + * borderColor โ†’ background grid lines and divider + */ +export const graphThemes: { [key: string]: Theme } = { + /** Emerald green โ€” Northern Lights feel */ + aurora: { + titleColor: '#a8ffce', + textColor: '#c8ffd4', + iconColor: '#00e676', + bgColor: '#020c12', + borderColor: '#0a3026', + }, + + /** Pure terminal green on black โ€” classic hacker aesthetic */ + matrix: { + titleColor: '#00ff41', + textColor: '#39ff14', + iconColor: '#00cc33', + bgColor: '#000000', + borderColor: '#003300', + }, + + /** Red-orange fire gradient โ€” heat/intensity */ + inferno: { + titleColor: '#ff9a00', + textColor: '#ffcf77', + iconColor: '#ff4500', + bgColor: '#0d0200', + borderColor: '#3d0a00', + }, + + /** Deep cyan-blue water โ€” calm and readable */ + ocean: { + titleColor: '#00d4ff', + textColor: '#b2f0ff', + iconColor: '#0099cc', + bgColor: '#020d1a', + borderColor: '#0a2a40', + }, + + /** Magenta / purple โ€” cyberpunk neon */ + neon: { + titleColor: '#ff00cc', + textColor: '#ff99ee', + iconColor: '#cc00ff', + bgColor: '#0a000f', + borderColor: '#3d0050', + }, + + /** Amber / gold โ€” warm solar glow */ + solar: { + titleColor: '#ffd700', + textColor: '#ffe88a', + iconColor: '#f5a623', + bgColor: '#0d0900', + borderColor: '#3a2800', + }, + + /** Violet / purple โ€” deep-space galaxy */ + galaxy: { + titleColor: '#c084fc', + textColor: '#e9d5ff', + iconColor: '#8b5cf6', + bgColor: '#05020f', + borderColor: '#1e0a40', + }, + + /** GitHub native dark palette โ€” green contribution cells */ + 'github-dark': { + titleColor: '#58a6ff', + textColor: '#c9d1d9', + iconColor: '#39d353', + bgColor: '#0d1117', + borderColor: '#21262d', + }, +}; diff --git a/src/shared/utils/username.ts b/src/shared/utils/username.ts new file mode 100644 index 0000000..526eddc --- /dev/null +++ b/src/shared/utils/username.ts @@ -0,0 +1,13 @@ +/** + * Canonical GitHub-username validator. + * + * GitHub's rules: 1โ€“39 chars, ASCII alphanumeric, single hyphens between + * alphanumerics only (no leading/trailing/double hyphens). Shared by every + * DB-write path so a malformed value never becomes a table row. + */ + +export const GITHUB_USERNAME_RE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; + +export function isValidGithubUsername(value: unknown): value is string { + return typeof value === 'string' && GITHUB_USERNAME_RE.test(value); +} diff --git a/src/shared/utils/visitor.ts b/src/shared/utils/visitor.ts new file mode 100644 index 0000000..64f4dde --- /dev/null +++ b/src/shared/utils/visitor.ts @@ -0,0 +1,42 @@ +/** + * Helpers for visitor dedup on the /badges visitors endpoint. + * + * The visitor counter is bumped once per (username, ip_hash, day). A per- + * instance `SERVER_SALT` is mixed into the IP before hashing so raw IPs + * never touch storage and hashes aren't portable between deployments. + */ + +import { createHash } from 'node:crypto'; +import { getEnv } from '../config/env.js'; + +// Fallback salt used only when SERVER_SALT is unset. The env loader logs a +// warning at boot; this constant exists so dev environments still function. +// Long enough (>= 32 chars) to be indistinguishable from a real salt at the +// hash function's output. +const DEV_FALLBACK_SALT = 'DEV-ONLY-INSECURE-DO-NOT-USE-IN-PROD-6f4b3a2e'; + +let cachedSalt: string | null = null; + +function getSalt(): string { + if (cachedSalt !== null) return cachedSalt; + cachedSalt = getEnv().SERVER_SALT ?? DEV_FALLBACK_SALT; + return cachedSalt; +} + +/** + * Deterministic SHA-256 of `${salt}:${ip}`. Length-fixed, storage-safe. + * Returns an empty string when the IP is missing so callers can decide to + * abort the write rather than dedup against a global bucket. + */ +export function hashClientIp(ip: string | null | undefined): string { + if (!ip) return ''; + return createHash('sha256').update(`${getSalt()}:${ip}`).digest('hex'); +} + +/** + * Today's date in `YYYY-MM-DD` (UTC). Aligns with the `visitor_logs.visit_date` + * column and gives every deployment the same day boundary regardless of TZ. + */ +export function currentVisitDateUtc(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/src/shared/validations/validation.ts b/src/shared/validations/validation.ts new file mode 100644 index 0000000..1c161fd --- /dev/null +++ b/src/shared/validations/validation.ts @@ -0,0 +1,213 @@ +/** + * Request Validation Schemas + * Provides runtime validation for API requests using Zod. + * + * These schemas define the contract at each route's boundary; when a request + * fails to parse, `validate()` middleware calls `next(error)` and the shared + * `errorHandler` maps ZodError โ†’ 400 with a formatted `details.fields` string. + */ + +import { z } from 'zod'; +import { isKnownTheme, isKnownBadgeTheme, normalizeThemeName } from '../utils/themes.js'; + +/** + * Common validations + */ + +// GitHub usernames: 1โ€“39 chars, alphanumeric or hyphens (no leading/trailing/consecutive hyphens). +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' +}); + +// Accept #RGB / #RGBA / #RRGGBB / #RRGGBBAA (with or without leading '#') and +// normalize to `#โ€ฆ` form. Mirrors src/shared/utils/svg-safe.ts:normalizeHexColor +// so controllers and Zod agree on the same character set. +const HEX_RE = /^#?[0-9a-fA-F]{3,8}$/; +const colorHex = z.string() + .refine( + (v) => { + if (!HEX_RE.test(v)) return false; + const body = v.startsWith('#') ? v.slice(1) : v; + return body.length === 3 || body.length === 4 || body.length === 6 || body.length === 8; + }, + { message: 'Invalid hex color; expected #RGB, #RGBA, #RRGGBB, or #RRGGBBAA (with or without #)' }, + ) + .transform((v) => (v.startsWith('#') ? v : `#${v}`)) + .optional(); + +// Tightened theme name โ€” only accept a value that resolves against the theme +// registry (case/underscore/space-insensitive, per resolveThemeName). The +// `.transform` canonicalises aliases (`Ocean` โ†’ `ocean`) so downstream code +// and cache keys never see two spellings of the same theme (M1). +const themeSchema = z.string() + .refine(isKnownTheme, { message: 'Unknown theme' }) + .transform(normalizeThemeName) + .optional(); + +// Badge endpoint accepts a CSV of themes; validate each item independently. +const badgeThemeCsvSchema = z.string() + .refine( + (v) => v.split(',').map((s) => s.trim()).filter(Boolean).every(isKnownBadgeTheme), + { message: 'Unknown theme (CSV; one or more entries not registered)' }, + ) + .optional(); + +const booleanString = z.enum(['true', 'false']).optional(); +const formatSchema = z.enum(['svg', 'webp', 'png']).optional(); +const sizeSchema = z.enum(['small', 'medium', 'large', 'default']).optional(); + +// Integer-string clamped to a range. Used for badge `column` and `p` (padding). +const intStringRange = (min: number, max: number) => + z.string() + .regex(/^\d+$/, 'Expected a non-negative integer') + .transform(Number) + .refine((n) => n >= min && n <= max, { message: `Expected an integer in [${min}, ${max}]` }) + .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, + size: sizeSchema, + year: z.string().regex(/^\d{4}$/, 'Expected a 4-digit year').optional(), +}); + +export type StatsQuery = z.infer; + +/** + * Languages card request schema + */ +export const languagesQuerySchema = z.object({ + username: githubUsername, + type: z.enum(['card', 'pie']).optional(), + theme: themeSchema, + show_info: booleanString, + info_outline: z.enum(['solid', 'frame']).optional(), + size: sizeSchema, +}); + +export type LanguagesQuery = z.infer; + +/** + * Graph request schema โ€” matches the controller's actual query surface + * (see graphs.controller.ts:parseQueryParams). + */ +export const graphQuerySchema = z.object({ + username: githubUsername, + theme: themeSchema, + year: z.string().regex(/^\d{4}$/, 'Expected a 4-digit year').optional(), + animate: z.enum(['none', 'wave', 'pulse', 'glow']).optional(), + size: z.string().optional(), + as: formatSchema, + format: formatSchema, + show_title: booleanString, + show_total_contribution: booleanString, + show_background: booleanString, + bgColor: colorHex, + borderColor: colorHex, + textColor: colorHex, + titleColor: colorHex, +}); + +export type GraphQuery = z.infer; + +/** + * Badge request schema โ€” mirrors badges.controller.ts:BadgeQueryParams. + * CSV fields (`name`, `theme`) are validated as raw strings; the controller + * splits and per-item validates against its own enum lists so we preserve the + * discovery-payload behavior when `name` is absent. + */ +export const badgeQuerySchema = z.object({ + username: githubUsername, + name: z.string().optional(), + repo: z.string().max(200).optional(), + theme: badgeThemeCsvSchema, + effect: z.enum(['wave', 'glow']).optional(), + column: intStringRange(1, 50), + size: z.enum(['small', 'medium', 'large']).optional(), + p: intStringRange(0, 100), + customLabel: z.string().max(50).optional(), + labelColor: colorHex, + labelBackground: colorHex, + iconColor: colorHex, + valueColor: colorHex, + valueBackground: colorHex, + hideFrame: booleanString, + realtime: booleanString, +}); + +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 (kept for callers that need a boolean check outside a + * Zod pipeline; matches the schema above). + */ +export function isValidHexColor(color: string): boolean { + if (!HEX_RE.test(color)) return false; + const body = color.startsWith('#') ? color.slice(1) : color; + return body.length === 3 || body.length === 4 || body.length === 6 || body.length === 8; +} + +/** + * 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/types.ts b/src/types.ts deleted file mode 100644 index 99e936c..0000000 --- a/src/types.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Re-export badge types from the dedicated badge types file -export type { BadgeType, UserBadgeType, ProjectBadgeType, BadgeTheme, BadgeOptions, BadgeRouteDoc } from './types/badge.types.js'; -export { BADGE_OPTIONAL_PARAMS } from './types/badge.types.js'; - -export interface GitHubStats { - name: string; - avatarUrl: string; - totalStars: number; - totalCommits: number; - totalPRs: number; - totalIssues: number; - contributedTo: number; - rank?: { - level: string; - score: number; - }; -} - -export interface LanguageCount { - name: string; - count: number; -} - -export interface Theme { - /** Card/graph title text color */ - titleColor: string; - /** General body text, labels, and subtitle color */ - textColor: string; - /** Icons, accents, graph heatmap cell fill color, and data chart color */ - iconColor: string; - /** Card/graph background fill color */ - bgColor: string; - /** Card border, divider lines, and grid line color */ - borderColor: string; - /** Font name (e.g. 'Orbitron') โ€” defaults to Orbitron */ - fontName?: string; - /** Full CSS font-family stack (e.g. "'Orbitron', sans-serif") */ - fontFamily?: string; - /** URL to the woff2 font file for @font-face embedding */ - fontUrl?: string; -} - -export interface ThemeOverrides { - theme?: string; - bgColor?: string; - borderColor?: string; - textColor?: string; - titleColor?: string; -} - -export interface StatsCardOptions extends ThemeOverrides { - hideTitle?: boolean; - hideBorder?: boolean; - hideRank?: boolean; - showIcons?: boolean; - customTitle?: string; - avatarMode?: 'none' | 'avatar' | 'radar'; - dataBorderStyle?: 'solid' | 'frame'; - dataBorderFramePosition?: 'in' | 'out'; -} - -export interface LanguagesCardOptions extends ThemeOverrides { - showInfo?: boolean; - listLength?: number; - variant?: 'bubbles' | 'pie'; - dataBorderStyle?: 'solid' | 'frame'; - dataBorderFramePosition?: 'in' | 'out'; -} - -export interface LanguagesPieChartOptions extends ThemeOverrides { - listLength?: number; -} - -export interface ContributionDay { - date: string; - count: number; - level: number; -} - -export interface ContributionGraphData { - username: string; - year: string | number; - totalContributions: number; - weeks: ContributionDay[][]; -} - -export interface GraphCardOptions extends ThemeOverrides { - year?: string | number; - animate?: 'none' | 'glow' | 'wave' | 'pulse'; - /** Output format. Default: 'svg'. Use 'webp', 'png', or 'gif' for raster conversion. */ - as?: 'svg' | 'webp' | 'png' | 'gif'; - /** Canvas size preset. 'default' = 1200ร—600, 'small' = 800ร—400, 'medium' = 1000ร—500, 'large' = 1400ร—700 */ - size?: 'small' | 'medium' | 'large' | 'default'; - /** Show/hide the title (username + year). When false, content is centered. Default: true */ - show_title?: boolean; - /** Show/hide the total contributions subtitle. When false, SVG height shrinks to fit content. Default: true */ - show_total_contribution?: boolean; - /** Show/hide the background (gradient, stars, grid lines). When false, bg is transparent and SVG width fits the cells. Default: true */ - show_background?: boolean; -} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..6517765 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,215 @@ +import { initializeD1 } from './db/index.js'; + +// Minimal Cloudflare Workers type stubs. +// Replace with `import type { ... } from '@cloudflare/workers-types'` once +// you have run `npm install` (the package is already in devDependencies). +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type D1Database = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KVNamespace = any; +interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; +} + +export interface Env { + /** Cloudflare D1 database binding โ€” declared in wrangler.toml [[d1_databases]] */ + DB: D1Database; + /** KV namespace for general stats caching */ + bind_stats: KVNamespace; + /** KV namespace for badge/response caching */ + bind_stats_cache: KVNamespace; + ENVIRONMENT?: string; + GITHUB_TOKEN?: string; + DEBUG?: string; +} + +/** + * Response helpers + */ + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body, null, 2), { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +} + +function svgResponse(body: string): Response { + return new Response(body, { + headers: { + 'content-type': 'image/svg+xml; charset=utf-8', + 'cache-control': 'public, max-age=600, s-maxage=1800, stale-while-revalidate=86400', + }, + }); +} + +/** + * Worker entry + */ + +export default { + async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise { + // Initialize Cloudflare D1 before any route handler runs. + // This populates the shared `db` proxy used by all service modules. + initializeD1(env.DB); + + const url = new URL(request.url); + const { pathname } = url; + const params = Object.fromEntries(url.searchParams) as Record; + const cacheDuration = 7_200_000; // 2 h + + try { + /** + * /health + */ + if (pathname === '/health' || pathname === '/health/') { + return json({ status: 'ok', environment: env.ENVIRONMENT ?? 'cloudflare' }); + } + + /** + * /badge or /badges + */ + if (pathname.startsWith('/badge')) { + const { BadgesService } = await import('./modules/badges/badges.service.js'); + const { GitHubClient } = await import('./shared/utils/github-client.js'); + const service = new BadgesService( + new GitHubClient(env.GITHUB_TOKEN), + new Map(), + cacheDuration, + ); + const username = params.username ?? ''; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const type = (params.type as any) ?? 'visitors'; + const result = await service.generateUserBadge(username, type, params); + return svgResponse(result); + } + + /** + * /stats + */ + if (pathname.startsWith('/stats')) { + const { StatsService } = await import('./modules/stats/stats.service.js'); + const { GitHubClient } = await import('./shared/utils/github-client.js'); + const service = new StatsService( + new GitHubClient(env.GITHUB_TOKEN), + new Map(), + cacheDuration, + ); + const result = await service.generateSvg(params as any); + return svgResponse(result); + } + + /** + * /graph + */ + if (pathname.startsWith('/graph')) { + const { GraphsService } = await import('./modules/graphs/graphs.service.js'); + const { GitHubClient } = await import('./shared/utils/github-client.js'); + const service = new GraphsService( + new GitHubClient(env.GITHUB_TOKEN), + new Map(), + cacheDuration, + ); + const result = await service.generateGraph(params as any); + return svgResponse(result); + } + + /** + * /languages + */ + if (pathname.startsWith('/languages')) { + const { LanguagesService } = await import('./modules/languages/languages.service.js'); + const { GitHubClient } = await import('./shared/utils/github-client.js'); + const service = new LanguagesService( + new GitHubClient(env.GITHUB_TOKEN), + new Map(), + cacheDuration, + ); + const result = await service.generateLanguageVisualization(params as any); + return svgResponse(result); + } + + /** + * /icons + */ + if (pathname.startsWith('/icons')) { + const { IconsService } = await import('./modules/icons/icons.service.js'); + const { IconsCollectionController } = await import('./modules/icons/icons-collection.controller.js'); + const service = new IconsService(); + + const iconPath = pathname + .replace(/^\/icons\/?/, '') + .replace(/\.svg$/i, '') + .trim(); + + if (iconPath.length > 0 && iconPath !== 'demo') { + const { content } = await service.getIcon(iconPath, params.color); + return svgResponse(content); + } + + const hasCollectionOptions = Boolean(params.color || params.size || params.effect || params.columns); + + if (params.name) { + // Wrap worker request/response into Express-like objects for the controller + const expressLikeReq = { + query: params, + headers: { 'if-none-match': request.headers.get('if-none-match') ?? undefined }, + } as any; + + let responseContent = ''; + let responseStatus = 200; + let responseHeaders: Record = {}; + + const expressLikeRes = { + status: (code: number) => { + responseStatus = code; + return expressLikeRes; + }, + json: (body: unknown) => { + responseContent = JSON.stringify(body, null, 2); + responseHeaders['content-type'] = 'application/json; charset=utf-8'; + }, + setHeader: (key: string, value: string) => { + responseHeaders[key.toLowerCase()] = value; + }, + send: (body: string) => { + responseContent = body; + }, + end: () => { + // No-op for worker + }, + } as any; + + await IconsCollectionController.getIconsCollection(expressLikeReq, expressLikeRes); + + if (responseStatus === 304) { + return new Response(null, { status: 304, headers: responseHeaders }); + } + + return new Response(responseContent, { + status: responseStatus, + headers: responseHeaders, + }); + } + + if (hasCollectionOptions) { + return new Response(JSON.stringify({ error: 'name is required' }, null, 2), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + } + + const icons = await service.loadIconsList(); + return json({ icons, count: icons.length }); + } + + return json({ error: 'Not Found', path: pathname }, 404); + } catch (err) { + // Log server-side; never surface raw messages to the client (M3). + console.error('Worker request failed', err); + return json({ error: 'Internal Server Error' }, 500); + } + }, +}; + diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..eb14d93 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,82 @@ +name = 'stats' +main = "src/worker.ts" +compatibility_date = "2024-12-16" +compatibility_flags = ["nodejs_compat"] + +# Build configuration +[build] +command = "npm run build" +cwd = "." + +# Environment variables (default/production) +[vars] +ENVIRONMENT = "cloudflare" +PORT = "3000" +UPSTREAM_ORIGIN = "https://stats.pphatdev.workers.dev" + +# D1 Database Binding - SQLite for Cloudflare +[[d1_databases]] +binding = "DB" +database_name = "stats" +database_id = "a2b03b75-e470-480a-acd4-89bad0b42794" + +# KV Namespace Bindings - for caching +[[kv_namespaces]] +binding = "bind_stats" +id = "31440109a66e43afb1edb018a6dd7637" + +[[kv_namespaces]] +binding = "bind_stats_cache" +id = "e1c34e6f806c419e885f45607e3b0a84" +preview_id = "e1c34e6f806c419e885f45607e3b0a84" + +# Development environment +[env.development] +name = "stats-dev" +main = "src/worker.ts" +compatibility_date = "2024-12-16" + +[env.development.vars] +ENVIRONMENT = "cloudflare-dev" +DEBUG = "true" +UPSTREAM_ORIGIN = "https://stats.pphatdev.workers.dev" + +[[env.development.d1_databases]] +binding = "DB" +database_name = "stats" +database_id = "a2b03b75-e470-480a-acd4-89bad0b42794" + +[[env.development.kv_namespaces]] +binding = "bind_stats" +id = "31440109a66e43afb1edb018a6dd7637" +preview_id = "31440109a66e43afb1edb018a6dd7637" + +[[env.development.kv_namespaces]] +binding = "bind_stats_cache" +id = "e1c34e6f806c419e885f45607e3b0a84" +preview_id = "e1c34e6f806c419e885f45607e3b0a84" + +# Production environment +[env.production] +name = "stats" +main = "src/worker.ts" +compatibility_date = "2024-12-16" + +[env.production.vars] +ENVIRONMENT = "cloudflare-production" +DEBUG = "false" +UPSTREAM_ORIGIN = "https://stats.pphatdev.workers.dev" + +[[env.production.d1_databases]] +binding = "DB" +database_name = "stats" +database_id = "a2b03b75-e470-480a-acd4-89bad0b42794" + +[[env.production.kv_namespaces]] +binding = "bind_stats" +id = "31440109a66e43afb1edb018a6dd7637" + +[[env.production.kv_namespaces]] +binding = "bind_stats_cache" +id = "e1c34e6f806c419e885f45607e3b0a84" +