Initial release v1.0.0

Open-source marketplace for AI Agent skills.

Features:
- Next.js 15 web app with i18n (en/fa)
- CLI tool for skill installation (npx skillhub)
- GitHub crawler/indexer with multi-strategy discovery
- Security scanning for all indexed skills
- Self-hostable with Docker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-12 06:08:51 +03:30
commit 97b427831a
227 changed files with 48411 additions and 0 deletions

105
apps/web/lib/auth.ts Normal file
View File

@@ -0,0 +1,105 @@
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { createDb, userQueries } from '@skillhub/db';
import { sendWelcomeEmail } from './email';
// Determine secure cookie prefix based on AUTH_URL protocol
const useSecureCookies = process.env.AUTH_URL?.startsWith('https://') ?? process.env.NODE_ENV === 'production';
const cookiePrefix = useSecureCookies ? '__Secure-' : '';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
// Custom profile to prevent "Cannot read properties of undefined (reading 'toString')"
// when GitHub API returns unexpected data (e.g. after a failed token exchange)
profile(profile) {
return {
id: String(profile.id ?? ''),
name: (profile.name ?? profile.login) as string,
email: profile.email as string | null,
image: profile.avatar_url as string | null,
};
},
}),
],
trustHost: true, // Trust Host header from reverse proxy (Coolify, Nginx, etc.)
session: { strategy: 'jwt' },
// Explicit cookie config to ensure PKCE works behind reverse proxies
cookies: {
pkceCodeVerifier: {
name: `${cookiePrefix}authjs.pkce.code_verifier`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies,
maxAge: 900, // 15 minutes
},
},
},
callbacks: {
async signIn({ profile }) {
if (!profile?.id) return false;
// On mirror servers the database is a read-only replica,
// so skip the upsert. The user session still works via JWT.
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
if (isPrimary) {
try {
const db = createDb();
// Check if user already exists (to detect first login)
const existingUser = await userQueries.getByGithubId(db, String(profile.id));
// Check if user is in the admin list
const adminUsers = (process.env.ADMIN_GITHUB_USERS || '')
.split(',')
.map((u) => u.trim().toLowerCase())
.filter(Boolean);
const isAdmin = adminUsers.includes((profile.login as string).toLowerCase());
await userQueries.upsertFromGithub(db, {
githubId: String(profile.id),
username: profile.login as string,
displayName: profile.name as string | undefined,
email: profile.email as string | undefined,
avatarUrl: profile.avatar_url as string | undefined,
isAdmin,
});
// For new users with an email, send welcome/onboarding email
// (no auto-subscribe to newsletter — user can opt-in via link in the email)
if (!existingUser && profile.email) {
const email = (profile.email as string).toLowerCase().trim();
sendWelcomeEmail(email, 'en', profile.login as string).catch((err) => {
console.error('[Auth] Failed to send welcome email:', err);
});
}
} catch (err) {
console.error('[Auth] Database error during sign-in (allowing login anyway):', err);
}
}
return true;
},
async jwt({ token, profile }) {
if (profile) {
token.githubId = String(profile.id);
token.username = profile.login;
token.avatarUrl = profile.avatar_url;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.githubId = token.githubId as string;
session.user.username = token.username as string;
session.user.avatarUrl = token.avatarUrl as string;
}
return session;
},
},
// Use default NextAuth pages - no custom pages needed
});

224
apps/web/lib/cache.ts Normal file
View File

@@ -0,0 +1,224 @@
import Redis from 'ioredis';
// Redis client singleton
let redis: Redis | null = null;
/**
* Get Redis client instance (singleton)
* Returns null if REDIS_URL is not set (graceful degradation)
*/
export function getRedis(): Redis | null {
if (redis) return redis;
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
// eslint-disable-next-line no-console
console.log('[Cache] REDIS_URL not set, caching disabled');
return null;
}
try {
redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy: (times) => {
if (times > 3) return null; // Stop retrying after 3 attempts
return Math.min(times * 100, 3000);
},
lazyConnect: true,
});
redis.on('error', (err) => {
// eslint-disable-next-line no-console
console.error('[Cache] Redis error:', err.message);
});
redis.on('connect', () => {
// eslint-disable-next-line no-console
console.log('[Cache] Redis connected');
});
return redis;
} catch (error) {
console.error('[Cache] Failed to initialize Redis:', error);
return null;
}
}
/**
* Get cached data by key
* Returns null if not cached or Redis unavailable
*/
export async function getCached<T>(key: string): Promise<T | null> {
const client = getRedis();
if (!client) return null;
try {
const data = await client.get(key);
if (!data) return null;
return JSON.parse(data) as T;
} catch (error) {
console.error(`[Cache] Error getting key ${key}:`, error);
return null;
}
}
/**
* Set cache with TTL (time-to-live in seconds)
*/
export async function setCache(
key: string,
data: unknown,
ttlSeconds: number
): Promise<void> {
const client = getRedis();
if (!client) return;
try {
await client.setex(key, ttlSeconds, JSON.stringify(data));
} catch (error) {
console.error(`[Cache] Error setting key ${key}:`, error);
}
}
/**
* Invalidate cache by exact key
*/
export async function invalidateCache(key: string): Promise<void> {
const client = getRedis();
if (!client) return;
try {
await client.del(key);
} catch (error) {
console.error(`[Cache] Error deleting key ${key}:`, error);
}
}
/**
* Invalidate cache by pattern (e.g., "skills:*")
* Use with caution - KEYS command can be slow on large datasets
*/
export async function invalidateCachePattern(pattern: string): Promise<void> {
const client = getRedis();
if (!client) return;
try {
const keys = await client.keys(pattern);
if (keys.length > 0) {
await client.del(...keys);
// eslint-disable-next-line no-console
console.log(`[Cache] Invalidated ${keys.length} keys matching ${pattern}`);
}
} catch (error) {
console.error(`[Cache] Error invalidating pattern ${pattern}:`, error);
}
}
/**
* Check if Redis is available and connected
*/
export async function isCacheAvailable(): Promise<boolean> {
const client = getRedis();
if (!client) return false;
try {
await client.ping();
return true;
} catch {
return false;
}
}
// Cache key builders for consistency
export const cacheKeys = {
stats: () => 'stats:global',
categories: () => 'categories:all',
featuredSkills: () => 'skills:featured',
recentSkills: () => 'skills:recent',
searchSkills: (hash: string) => `skills:search:${hash}`,
skill: (id: string) => `skill:${id.replace(/\//g, ':')}`,
skillView: (skillId: string, ip: string) => `view:${skillId.replace(/\//g, ':')}:${ip}`,
skillDownload: (skillId: string, ip: string) => `download:${skillId.replace(/\//g, ':')}:${ip}`,
};
// TTL values in seconds
export const cacheTTL = {
stats: 60 * 60, // 1 hour
categories: 12 * 60 * 60, // 12 hours
featured: 2 * 60 * 60, // 2 hours
recent: 60 * 60, // 1 hour
search: 30 * 60, // 30 minutes
skill: 60 * 60, // 1 hour
view: 60 * 60, // 1 hour - same IP can only count as 1 view per hour
download: 5 * 60, // 5 minutes - same IP can only count as 1 download per 5 min
};
/**
* Check if a view should be counted for this IP + skill combination.
* Returns true if this is a new view (should be counted), false if already viewed recently.
* Uses Redis to track views per IP with 1-hour TTL.
*/
export async function shouldCountView(skillId: string, ip: string): Promise<boolean> {
const client = getRedis();
// If Redis is not available, always count (graceful degradation)
if (!client) return true;
const key = cacheKeys.skillView(skillId, ip);
try {
// Try to set the key with NX (only if not exists) and EX (expiry)
// Returns 'OK' if set successfully (new view), null if already exists
const result = await client.set(key, '1', 'EX', cacheTTL.view, 'NX');
return result === 'OK';
} catch (error) {
console.error(`[Cache] Error checking view for ${skillId}:`, error);
// On error, count the view (graceful degradation)
return true;
}
}
/**
* Check if a download should be counted for this IP + skill combination.
* Returns true if this is a new download (should be counted), false if already downloaded recently.
* Uses Redis to track downloads per IP with 5-minute TTL (shorter than views to allow re-downloads).
*/
export async function shouldCountDownload(skillId: string, ip: string): Promise<boolean> {
const client = getRedis();
// If Redis is not available, always count (graceful degradation)
if (!client) return true;
const key = cacheKeys.skillDownload(skillId, ip);
try {
// Try to set the key with NX (only if not exists) and EX (expiry)
// Returns 'OK' if set successfully (new download), null if already exists
const result = await client.set(key, '1', 'EX', cacheTTL.download, 'NX');
return result === 'OK';
} catch (error) {
console.error(`[Cache] Error checking download for ${skillId}:`, error);
// On error, count the download (graceful degradation)
return true;
}
}
/**
* Generate a simple hash for search query parameters
*/
export function hashSearchParams(params: Record<string, string | number | undefined>): string {
const sorted = Object.entries(params)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&');
// Simple hash function
let hash = 0;
for (let i = 0; i < sorted.length; i++) {
const char = sorted.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}

114
apps/web/lib/csrf-client.ts Normal file
View File

@@ -0,0 +1,114 @@
'use client';
/**
* Client-side CSRF utilities for @edge-csrf/nextjs
*
* The CSRF token is received in the 'x-csrf-token' response header from the server.
* We store it in memory and include it in subsequent requests.
* For API requests, include the token in the 'x-csrf-token' header.
* For form submissions, include it in a hidden field named '_csrf'.
*/
const CSRF_HEADER_NAME = 'x-csrf-token';
// Store the CSRF token in memory (will be populated on first page load)
let csrfToken: string | null = null;
/**
* Set the CSRF token (called when receiving a response with the token)
*/
export function setCsrfToken(token: string): void {
csrfToken = token;
}
/**
* Get the current CSRF token
*/
export function getCsrfToken(): string | null {
return csrfToken;
}
/**
* Create headers object with CSRF token included
* Use this when making fetch requests
*/
export function createHeadersWithCsrf(additionalHeaders?: HeadersInit): Headers {
const headers = new Headers(additionalHeaders);
const token = getCsrfToken();
if (token) {
headers.set(CSRF_HEADER_NAME, token);
}
return headers;
}
/**
* Create a fetch wrapper that automatically includes the CSRF token
* and extracts new tokens from responses
*/
export async function fetchWithCsrf(
url: string | URL,
options?: RequestInit
): Promise<Response> {
const token = getCsrfToken();
const headers = new Headers(options?.headers);
if (token) {
headers.set(CSRF_HEADER_NAME, token);
}
// Ensure content-type is set for JSON requests if body is present
if (options?.body && typeof options.body === 'string' && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(url, {
...options,
headers,
});
// Update token from response if present
const newToken = response.headers.get(CSRF_HEADER_NAME);
if (newToken) {
setCsrfToken(newToken);
}
return response;
}
/**
* Initialize CSRF token by making a request to any page/API
* Call this early in your app (e.g., in a layout or provider)
*/
export async function initializeCsrfToken(): Promise<string | null> {
try {
// Make a simple GET request to get the CSRF token
const response = await fetch('/api/health', {
method: 'GET',
credentials: 'same-origin',
});
const token = response.headers.get(CSRF_HEADER_NAME);
if (token) {
setCsrfToken(token);
}
return token;
} catch {
console.warn('Failed to initialize CSRF token');
return null;
}
}
/**
* Get the header name for CSRF token (useful for custom request libraries)
*/
export function getCsrfHeaderName(): string {
return CSRF_HEADER_NAME;
}
/**
* Export constants for use in forms
*/
export const CSRF_FIELD_NAME = '_csrf';

63
apps/web/lib/csrf.ts Normal file
View File

@@ -0,0 +1,63 @@
import { createCsrfProtect, CsrfError } from '@edge-csrf/nextjs';
import { NextResponse } from 'next/server';
// CSRF token configuration
// Using double submit cookie pattern:
// - Secret is stored in httpOnly cookie (secure from XSS)
// - Token is stored in a readable cookie for JavaScript access
export const csrfProtect = createCsrfProtect({
cookie: {
// Secret cookie settings (httpOnly - not readable by JS)
name: '__csrf_secret',
path: '/',
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
},
// Token settings
token: {
// Response header where token is sent back to client
responseHeader: 'x-csrf-token',
},
});
// Error response for CSRF validation failure
export function createCsrfErrorResponse(): NextResponse {
return NextResponse.json(
{
error: 'CSRF token validation failed',
code: 'CSRF_ERROR',
message: 'Invalid or missing CSRF token. Please refresh the page and try again.',
},
{ status: 403 }
);
}
// Check if request method requires CSRF protection
export function requiresCsrfProtection(method: string): boolean {
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
return !safeMethods.includes(method.toUpperCase());
}
// Check if path should be protected
export function shouldProtectPath(pathname: string): boolean {
// Only protect API routes
if (!pathname.startsWith('/api/')) {
return false;
}
// List of API routes that need CSRF protection (state-changing)
const protectedRoutes = [
'/api/favorites', // POST (add), DELETE (remove)
'/api/favorites/check', // POST (batch check - read-like but uses POST)
'/api/ratings', // POST (submit rating)
'/api/skills/removal-request', // POST (request removal)
'/api/skills/add-request', // POST (request addition)
];
// Check if the pathname matches any protected route
return protectedRoutes.some((route) => pathname.startsWith(route));
}
// Export the CsrfError type for error handling
export { CsrfError };

View File

@@ -0,0 +1,438 @@
type Locale = 'en' | 'fa';
const SITE_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
const COLORS = {
primary: '#0284c7',
primaryDark: '#0369a1',
background: '#f8fafc',
surface: '#ffffff',
border: '#e2e8f0',
text: '#1e293b',
textSecondary: '#64748b',
textMuted: '#94a3b8',
};
function getDir(locale: Locale): 'rtl' | 'ltr' {
return locale === 'fa' ? 'rtl' : 'ltr';
}
function getAlign(locale: Locale): 'right' | 'left' {
return locale === 'fa' ? 'right' : 'left';
}
function getFontFamily(locale: Locale): string {
return locale === 'fa'
? 'Tahoma, Arial, sans-serif'
: 'Arial, Helvetica, sans-serif';
}
/**
* Shared base layout wrapper for all email templates
*/
function baseLayout(locale: Locale, bodyContent: string, footerContent: string): string {
const dir = getDir(locale);
const align = getAlign(locale);
const font = getFontFamily(locale);
return `<!DOCTYPE html>
<html lang="${locale}" dir="${dir}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SkillHub</title>
</head>
<body style="margin: 0; padding: 0; background-color: ${COLORS.background}; font-family: ${font}; direction: ${dir};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: ${COLORS.background};">
<tr>
<td align="center" style="padding: 32px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width: 600px; width: 100%;">
<!-- Header -->
<tr>
<td style="background-color: ${COLORS.primary}; padding: 24px 32px; border-radius: 12px 12px 0 0;">
<a href="${SITE_URL}" style="color: #ffffff; text-decoration: none; font-size: 24px; font-weight: bold; font-family: ${font};">
SkillHub
</a>
</td>
</tr>
<!-- Body -->
<tr>
<td style="background-color: ${COLORS.surface}; padding: 32px; text-align: ${align}; font-family: ${font};">
${bodyContent}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: ${COLORS.background}; padding: 24px 32px; border-top: 1px solid ${COLORS.border}; border-radius: 0 0 12px 12px; text-align: center;">
${footerContent}
<p style="margin: 12px 0 0 0; color: ${COLORS.textMuted}; font-size: 12px; font-family: ${font};">
&copy; ${new Date().getFullYear()} SkillHub &mdash;
<a href="${SITE_URL}" style="color: ${COLORS.textMuted}; text-decoration: underline;">${new URL(SITE_URL).hostname}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function ctaButton(locale: Locale, text: string, href: string): string {
const font = getFontFamily(locale);
return `<a href="${href}" style="display: inline-block; background-color: ${COLORS.primary}; color: #ffffff; padding: 12px 28px; border-radius: 8px; text-decoration: none; font-weight: bold; font-size: 14px; font-family: ${font}; margin: 4px;">${text}</a>`;
}
// ============================================================================
// Translation strings
// ============================================================================
const translations = {
en: {
welcome: {
subject: 'Welcome to SkillHub!',
greeting: (username: string) => `Hi ${username},`,
intro: 'Welcome to SkillHub — the open-source marketplace where AI agents find the expertise they need.',
whatIs: (count: string) => `Browse ${count}+ skills that work with Claude Code, GitHub Copilot, OpenAI Codex, Cursor, and Windsurf.`,
getStarted: 'Get started:',
browseSkills: 'Browse Skills',
installCli: 'Install CLI',
docs: 'Documentation',
newsletterCta: 'Want to stay updated? Subscribe to our newsletter for new skills, features, and tips.',
subscribeLink: 'Subscribe to Newsletter',
footer: 'You received this because you signed up on SkillHub with GitHub.',
},
newsletter: {
subject: 'You\'re in — welcome to SkillHub Newsletter',
greeting: 'Thanks for subscribing!',
intro: 'You\'ll get concise updates on what matters:',
item1: 'New and trending skills in the marketplace',
item2: 'Feature releases and platform improvements',
item3: 'Practical tips for AI-powered development',
visitUs: 'Visit SkillHub',
footer: 'You received this because you subscribed to the SkillHub newsletter.',
unsubscribe: 'Unsubscribe',
},
claimSubmitted: {
addSubject: 'Your skill request has been submitted',
removeSubject: 'Your removal request has been processed',
addGreeting: 'Your request has been submitted!',
removeGreeting: 'Your removal request has been processed!',
addIntro: (repoUrl: string, count: number) =>
`We received your request to add <strong>${repoUrl}</strong>${count > 0 ? ` (${count} skill${count > 1 ? 's' : ''} found)` : ''}.`,
removeIntro: (skillId: string) =>
`Your request to remove <strong>${skillId}</strong> has been verified and processed.`,
addPending: 'Your request is now pending review. We\'ll notify you when it\'s been processed.',
removeApproved: 'The skill has been blocked from indexing and will no longer appear on SkillHub.',
viewRequests: 'View My Requests',
footer: 'You received this because you submitted a request on SkillHub.',
},
claimStatus: {
approvedSubject: 'Your request has been approved',
rejectedSubject: 'Update on your request',
approvedGreeting: 'Good news!',
rejectedGreeting: 'Update on your request',
addApproved: (repoUrl: string) =>
`Your request to add <strong>${repoUrl}</strong> has been approved and will be indexed soon.`,
addRejected: (repoUrl: string) =>
`After review, your request to add <strong>${repoUrl}</strong> could not be approved at this time.`,
removeApproved: (skillId: string) =>
`Your request to remove <strong>${skillId}</strong> has been approved. The skill has been blocked from indexing.`,
removeRejected: (skillId: string) =>
`After review, your request to remove <strong>${skillId}</strong> could not be approved at this time.`,
viewRequests: 'View My Requests',
footer: 'You received this because you submitted a request on SkillHub.',
},
},
fa: {
welcome: {
subject: 'به SkillHub خوش آمدید!',
greeting: (username: string) => `سلام ${username}،`,
intro: 'به SkillHub خوش آمدید — بازار متن‌باز مهارت‌هایی که عامل‌های هوش مصنوعی به آن نیاز دارند.',
whatIs: (count: string) => `بیش از ${count} مهارت را مرور کنید که با Claude Code، GitHub Copilot، OpenAI Codex، Cursor و Windsurf کار می‌کنند.`,
getStarted: 'شروع کنید:',
browseSkills: 'مرور مهارت‌ها',
installCli: 'نصب CLI',
docs: 'مستندات',
newsletterCta: 'می‌خواهید به‌روز بمانید؟ در خبرنامه ما برای مهارت‌ها، ویژگی‌ها و نکات جدید عضو شوید.',
subscribeLink: 'عضویت در خبرنامه',
footer: 'این ایمیل به دلیل ثبت‌نام شما در SkillHub با GitHub ارسال شده است.',
},
newsletter: {
subject: 'عضویت شما فعال شد — خبرنامه SkillHub',
greeting: 'ممنون از عضویت شما!',
intro: 'به‌روزرسانی‌های مختصر و مفید دریافت خواهید کرد:',
item1: 'مهارت‌های جدید و پرطرفدار در بازار',
item2: 'ویژگی‌های جدید و بهبودهای پلتفرم',
item3: 'نکات عملی برای توسعه با هوش مصنوعی',
visitUs: 'مشاهده SkillHub',
footer: 'این ایمیل به دلیل عضویت شما در خبرنامه SkillHub ارسال شده است.',
unsubscribe: 'لغو عضویت',
},
claimSubmitted: {
addSubject: 'درخواست افزودن مهارت شما ثبت شد',
removeSubject: 'درخواست حذف مهارت شما پردازش شد',
addGreeting: 'درخواست شما ثبت شد!',
removeGreeting: 'درخواست حذف شما پردازش شد!',
addIntro: (repoUrl: string, count: number) =>
`درخواست افزودن <strong>${repoUrl}</strong>${count > 0 ? ` (${count} مهارت یافت شد)` : ''} دریافت شد.`,
removeIntro: (skillId: string) =>
`درخواست حذف <strong>${skillId}</strong> تایید و پردازش شد.`,
addPending: 'درخواست شما در انتظار بررسی است. پس از پردازش به شما اطلاع خواهیم داد.',
removeApproved: 'مهارت از ایندکس شدن مسدود شد و دیگر در SkillHub نمایش داده نخواهد شد.',
viewRequests: 'مشاهده درخواست‌ها',
footer: 'این ایمیل به دلیل ثبت درخواست شما در SkillHub ارسال شده است.',
},
claimStatus: {
approvedSubject: 'درخواست شما تایید شد',
rejectedSubject: 'به‌روزرسانی درخواست شما',
approvedGreeting: 'خبر خوب!',
rejectedGreeting: 'به‌روزرسانی درخواست شما',
addApproved: (repoUrl: string) =>
`درخواست افزودن <strong>${repoUrl}</strong> تایید شد و به زودی ایندکس خواهد شد.`,
addRejected: (repoUrl: string) =>
`پس از بررسی، درخواست افزودن <strong>${repoUrl}</strong> در حال حاضر قابل تایید نیست.`,
removeApproved: (skillId: string) =>
`درخواست حذف <strong>${skillId}</strong> تایید شد. مهارت از ایندکس شدن مسدود شد.`,
removeRejected: (skillId: string) =>
`پس از بررسی، درخواست حذف <strong>${skillId}</strong> در حال حاضر قابل تایید نیست.`,
viewRequests: 'مشاهده درخواست‌ها',
footer: 'این ایمیل به دلیل ثبت درخواست شما در SkillHub ارسال شده است.',
},
},
} as const;
// ============================================================================
// Template Builders
// ============================================================================
export interface EmailTemplate {
subject: string;
html: string;
}
/**
* Welcome email for new users (first GitHub OAuth login)
*/
export function buildWelcomeEmail(locale: Locale, username: string, email?: string, totalSkillCount?: number): EmailTemplate {
const t = translations[locale].welcome;
const dir = getDir(locale);
const align = getAlign(locale);
// Format skill count: dynamic if provided, fallback to "172,000+"
const skillCountStr = totalSkillCount
? (locale === 'fa'
? totalSkillCount.toLocaleString('fa-IR')
: totalSkillCount.toLocaleString('en-US'))
: (locale === 'fa' ? '۱۷۲,۰۰۰' : '172,000');
const body = `
<h1 style="margin: 0 0 16px 0; color: ${COLORS.text}; font-size: 24px; text-align: ${align};">
${t.greeting(username)}
</h1>
<p style="margin: 0 0 12px 0; color: ${COLORS.text}; font-size: 15px; line-height: 1.6;">
${t.intro}
</p>
<p style="margin: 0 0 20px 0; color: ${COLORS.textSecondary}; font-size: 14px; line-height: 1.6;">
${t.whatIs(skillCountStr)}
</p>
<p style="margin: 0 0 12px 0; color: ${COLORS.text}; font-size: 15px; font-weight: bold;">
${t.getStarted}
</p>
<div style="text-align: center; margin: 20px 0;">
${ctaButton(locale, t.browseSkills, `${SITE_URL}/${locale}/browse`)}
${ctaButton(locale, t.docs, `${SITE_URL}/${locale}/docs`)}
</div>
<p style="margin: 0 0 4px 0; color: ${COLORS.textSecondary}; font-size: 13px;">
${locale === 'en' ? 'Install CLI:' : 'نصب CLI:'}
</p>
<div style="background-color: ${COLORS.background}; border: 1px solid ${COLORS.border}; border-radius: 6px; padding: 10px 14px; margin: 0 0 16px 0;" dir="ltr">
<code style="font-family: 'Courier New', monospace; font-size: 13px; color: ${COLORS.text};">npm install -g skillhub</code>
</div>
<div style="border-left: 3px solid ${COLORS.primary}; padding: 0 0 0 14px; margin: 0 0 24px 0;">
<p style="margin: 0 0 4px 0; color: ${COLORS.text}; font-size: 13px; font-weight: bold;">
${locale === 'en' ? '&#128161; Tip' : '&#128161; نکته'}
</p>
<p style="margin: 0 0 6px 0; color: ${COLORS.textSecondary}; font-size: 12px; line-height: 1.5;">
${locale === 'en'
? 'Skills work best when explicitly invoked. Try searching and installing on the fly:'
: 'مهارت\u200Cها وقتی بهترین عملکرد را دارند که صریحاً فراخوانی شوند. جستجو و نصب لحظه\u200Cای را امتحان کنید:'}
</p>
<div style="background-color: #1e293b; border-radius: 4px; padding: 8px 12px; margin: 0;" dir="ltr">
<code style="font-family: 'Courier New', monospace; font-size: 11px; color: #e2e8f0; line-height: 1.7;">
npx skillhub search "react testing" --sort stars<br/>
npx skillhub install &lt;skill-id&gt; --project
</code>
</div>
</div>
<div style="border-top: 1px solid ${COLORS.border}; padding-top: 20px; margin-top: 8px;">
<p style="margin: 0 0 12px 0; color: ${COLORS.textSecondary}; font-size: 14px; line-height: 1.6;">
${t.newsletterCta}
</p>
<div style="text-align: ${dir === 'rtl' ? 'right' : 'left'};">
<a href="${SITE_URL}/api/newsletter/subscribe?email=${email ? encodeURIComponent(email) : ''}&locale=${locale}" style="color: ${COLORS.primary}; text-decoration: underline; font-size: 14px;">
${t.subscribeLink} &rarr;
</a>
</div>
</div>`;
const footer = `
<p style="margin: 0; color: ${COLORS.textMuted}; font-size: 12px;">
${t.footer}
</p>`;
return {
subject: t.subject,
html: baseLayout(locale, body, footer),
};
}
/**
* Newsletter welcome email for actual newsletter subscribers
*/
export function buildNewsletterWelcomeEmail(locale: Locale, email: string): EmailTemplate {
const t = translations[locale].newsletter;
const align = getAlign(locale);
const body = `
<h1 style="margin: 0 0 16px 0; color: ${COLORS.text}; font-size: 24px; text-align: ${align};">
${t.greeting}
</h1>
<p style="margin: 0 0 16px 0; color: ${COLORS.text}; font-size: 15px; line-height: 1.6;">
${t.intro}
</p>
<ul style="margin: 0 0 24px 0; padding: 0 0 0 ${locale === 'fa' ? '0' : '20px'}; ${locale === 'fa' ? 'padding-right: 20px; list-style-position: inside;' : ''} color: ${COLORS.textSecondary}; font-size: 14px; line-height: 2;">
<li>${t.item1}</li>
<li>${t.item2}</li>
<li>${t.item3}</li>
</ul>
<div style="background-color: ${COLORS.background}; border-radius: 6px; padding: 12px 16px; margin: 0 0 20px 0;">
<p style="margin: 0; color: ${COLORS.textSecondary}; font-size: 13px; line-height: 1.6;">
&#128161; ${locale === 'en'
? `<strong>Tip:</strong> Skills work best when explicitly invoked. Try searching and installing on the fly: <code style="background: ${COLORS.surface}; padding: 1px 4px; border-radius: 3px; font-size: 12px;">npx skillhub search &quot;your topic&quot;</code>. After installing, read the SKILL.md and follow its instructions. <a href="${SITE_URL}/${locale}/docs/cli" style="color: ${COLORS.primary}; text-decoration: none;">See CLI docs &rarr;</a>`
: `<strong>نکته:</strong> مهارت\u200Cها وقتی بهترین عملکرد را دارند که صریحاً فراخوانی شوند. جستجو و نصب لحظه\u200Cای را امتحان کنید: <code style="background: ${COLORS.surface}; padding: 1px 4px; border-radius: 3px; font-size: 12px;" dir="ltr">npx skillhub search &quot;your topic&quot;</code>. پس از نصب، فایل SKILL.md را بخوانید و دستورالعمل\u200Cها را دنبال کنید. <a href="${SITE_URL}/${locale}/docs/cli" style="color: ${COLORS.primary}; text-decoration: none;">مستندات CLI &rarr;</a>`}
</p>
</div>
<div style="text-align: center; margin: 24px 0;">
${ctaButton(locale, t.visitUs, `${SITE_URL}/${locale}`)}
</div>`;
const unsubscribeUrl = `${SITE_URL}/api/newsletter/unsubscribe?email=${encodeURIComponent(email)}`;
const footer = `
<p style="margin: 0; color: ${COLORS.textMuted}; font-size: 12px;">
${t.footer}
<br />
<a href="${unsubscribeUrl}" style="color: ${COLORS.textMuted}; text-decoration: underline;">${t.unsubscribe}</a>
</p>`;
return {
subject: t.subject,
html: baseLayout(locale, body, footer),
};
}
/**
* Claim submission confirmation email
*/
export function buildClaimSubmittedEmail(
locale: Locale,
type: 'add' | 'remove',
details: { skillId?: string; repositoryUrl?: string; skillCount?: number }
): EmailTemplate {
const t = translations[locale].claimSubmitted;
const align = getAlign(locale);
const subject = type === 'add' ? t.addSubject : t.removeSubject;
const greeting = type === 'add' ? t.addGreeting : t.removeGreeting;
let introText: string;
let statusText: string;
if (type === 'add') {
introText = t.addIntro(details.repositoryUrl || '', details.skillCount || 0);
statusText = t.addPending;
} else {
introText = t.removeIntro(details.skillId || '');
statusText = t.removeApproved;
}
const body = `
<h1 style="margin: 0 0 16px 0; color: ${COLORS.text}; font-size: 24px; text-align: ${align};">
${greeting}
</h1>
<p style="margin: 0 0 12px 0; color: ${COLORS.text}; font-size: 15px; line-height: 1.6;">
${introText}
</p>
<p style="margin: 0 0 24px 0; color: ${COLORS.textSecondary}; font-size: 14px; line-height: 1.6;">
${statusText}
</p>
<div style="text-align: center; margin: 24px 0;">
${ctaButton(locale, t.viewRequests, `${SITE_URL}/${locale}/claim`)}
</div>`;
const footer = `
<p style="margin: 0; color: ${COLORS.textMuted}; font-size: 12px;">
${t.footer}
</p>`;
return {
subject,
html: baseLayout(locale, body, footer),
};
}
/**
* Claim status change notification email
*/
export function buildClaimStatusEmail(
locale: Locale,
type: 'add' | 'remove',
status: 'approved' | 'rejected',
details: { skillId?: string; repositoryUrl?: string }
): EmailTemplate {
const t = translations[locale].claimStatus;
const align = getAlign(locale);
const subject = status === 'approved' ? t.approvedSubject : t.rejectedSubject;
const greeting = status === 'approved' ? t.approvedGreeting : t.rejectedGreeting;
let messageText: string;
if (type === 'add' && status === 'approved') {
messageText = t.addApproved(details.repositoryUrl || '');
} else if (type === 'add' && status === 'rejected') {
messageText = t.addRejected(details.repositoryUrl || '');
} else if (type === 'remove' && status === 'approved') {
messageText = t.removeApproved(details.skillId || '');
} else {
messageText = t.removeRejected(details.skillId || '');
}
const body = `
<h1 style="margin: 0 0 16px 0; color: ${COLORS.text}; font-size: 24px; text-align: ${align};">
${greeting}
</h1>
<p style="margin: 0 0 24px 0; color: ${COLORS.text}; font-size: 15px; line-height: 1.6;">
${messageText}
</p>
<div style="text-align: center; margin: 24px 0;">
${ctaButton(locale, t.viewRequests, `${SITE_URL}/${locale}/claim`)}
</div>`;
const footer = `
<p style="margin: 0; color: ${COLORS.textMuted}; font-size: 12px;">
${t.footer}
</p>`;
return {
subject,
html: baseLayout(locale, body, footer),
};
}
// ============================================================================
// Outreach Email Template (English only — for GitHub repo owners)
// ============================================================================

179
apps/web/lib/email.ts Normal file
View File

@@ -0,0 +1,179 @@
import { Resend } from 'resend';
import {
buildWelcomeEmail,
buildNewsletterWelcomeEmail,
buildClaimSubmittedEmail,
buildClaimStatusEmail,
} from './email-templates';
type Locale = 'en' | 'fa';
// Singleton Resend client
let resendClient: Resend | null = null;
function getResend(): Resend | null {
if (resendClient) return resendClient;
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
console.warn('[Email] RESEND_API_KEY not configured');
return null;
}
resendClient = new Resend(apiKey);
return resendClient;
}
const FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'SkillHub <onboarding@resend.dev>';
/**
* Send a welcome/onboarding email to new users (first GitHub OAuth login)
*/
export async function sendWelcomeEmail(
to: string,
locale: Locale = 'en',
username: string = ''
): Promise<boolean> {
const resend = getResend();
if (!resend) return false;
try {
const template = buildWelcomeEmail(locale, username || to.split('@')[0], to);
const result = await resend.emails.send({
from: FROM_EMAIL,
to,
subject: template.subject,
html: template.html,
});
if (result.error) {
console.error('[Email] Resend API error (welcome):', result.error);
return false;
}
return true;
} catch (error) {
console.error('[Email] Failed to send welcome email:', error);
return false;
}
}
/**
* Send a newsletter welcome email to new newsletter subscribers
*/
export async function sendNewsletterWelcomeEmail(
to: string,
locale: Locale = 'en'
): Promise<boolean> {
const resend = getResend();
if (!resend) return false;
try {
const template = buildNewsletterWelcomeEmail(locale, to);
const result = await resend.emails.send({
from: FROM_EMAIL,
to,
subject: template.subject,
html: template.html,
});
if (result.error) {
console.error('[Email] Resend API error (newsletter):', result.error);
return false;
}
return true;
} catch (error) {
console.error('[Email] Failed to send newsletter welcome email:', error);
return false;
}
}
/**
* Send a claim submission confirmation email
*/
export async function sendClaimSubmittedEmail(
to: string,
locale: Locale = 'en',
type: 'add' | 'remove',
details: { skillId?: string; repositoryUrl?: string; skillCount?: number }
): Promise<boolean> {
const resend = getResend();
if (!resend) return false;
try {
const template = buildClaimSubmittedEmail(locale, type, details);
const result = await resend.emails.send({
from: FROM_EMAIL,
to,
subject: template.subject,
html: template.html,
});
if (result.error) {
console.error('[Email] Resend API error (claim submitted):', result.error);
return false;
}
return true;
} catch (error) {
console.error('[Email] Failed to send claim submitted email:', error);
return false;
}
}
/**
* Send a claim status change notification email
*/
export async function sendClaimStatusEmail(
to: string,
locale: Locale = 'en',
type: 'add' | 'remove',
status: 'approved' | 'rejected',
details: { skillId?: string; repositoryUrl?: string }
): Promise<boolean> {
const resend = getResend();
if (!resend) return false;
try {
const template = buildClaimStatusEmail(locale, type, status, details);
const result = await resend.emails.send({
from: FROM_EMAIL,
to,
subject: template.subject,
html: template.html,
});
if (result.error) {
console.error('[Email] Resend API error (claim status):', result.error);
return false;
}
return true;
} catch (error) {
console.error('[Email] Failed to send claim status email:', error);
return false;
}
}
/**
* Send a generic email via Resend
*/
export async function sendEmail(options: {
to: string;
subject: string;
html: string;
}): Promise<boolean> {
const resend = getResend();
if (!resend) return false;
try {
const result = await resend.emails.send({
from: FROM_EMAIL,
to: options.to,
subject: options.subject,
html: options.html,
});
if (result.error) {
console.error('[Email] Resend API error:', result.error);
return false;
}
return true;
} catch (error) {
console.error('[Email] Failed to send email:', error);
return false;
}
}

94
apps/web/lib/env.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* Environment variable validation
* Validates required environment variables at startup
*/
interface EnvValidationResult {
valid: boolean;
missing: string[];
warnings: string[];
}
// Required environment variables (app will fail without these)
const REQUIRED_ENV_VARS = ['DATABASE_URL'];
// Recommended environment variables (app works but with reduced functionality)
const RECOMMENDED_ENV_VARS = ['GITHUB_TOKEN', 'AUTH_SECRET', 'GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET'];
// Optional environment variables (nice to have)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const OPTIONAL_ENV_VARS = ['MEILI_URL', 'MEILI_MASTER_KEY', 'REDIS_URL'];
/**
* Validate environment variables
* Call this at application startup to fail fast if critical env vars are missing
*/
export function validateEnv(): EnvValidationResult {
const missing: string[] = [];
const warnings: string[] = [];
// Check required variables
for (const envVar of REQUIRED_ENV_VARS) {
if (!process.env[envVar]) {
missing.push(envVar);
}
}
// Check recommended variables (warn but don't fail)
for (const envVar of RECOMMENDED_ENV_VARS) {
if (!process.env[envVar]) {
warnings.push(`${envVar} not set - some features may not work`);
}
}
// Specific warnings for missing optional features
if (!process.env.GITHUB_TOKEN) {
warnings.push('GITHUB_TOKEN not set - limited to 60 GitHub API requests/hour');
}
if (!process.env.MEILI_URL) {
warnings.push('MEILI_URL not set - using PostgreSQL for search (slower)');
}
return {
valid: missing.length === 0,
missing,
warnings,
};
}
/**
* Validate and throw if critical env vars are missing
* Use this in server components/API routes for fail-fast behavior
*/
export function requireEnv(): void {
const result = validateEnv();
if (!result.valid) {
throw new Error(`Missing required environment variables: ${result.missing.join(', ')}`);
}
// Log warnings in development
if (process.env.NODE_ENV === 'development' && result.warnings.length > 0) {
console.warn('Environment warnings:');
result.warnings.forEach((w) => console.warn(` - ${w}`));
}
}
/**
* Get a required environment variable or throw
*/
export function getRequiredEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Required environment variable ${key} is not set`);
}
return value;
}
/**
* Get an optional environment variable with a default value
*/
export function getOptionalEnv(key: string, defaultValue: string): string {
return process.env[key] || defaultValue;
}

View File

@@ -0,0 +1,64 @@
/**
* Number formatting utilities for localization
* Supports Persian (Farsi) digit conversion
*/
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
/**
* Convert a number or string to Persian digits
*/
export function toPersianNumber(num: number | string): string {
return String(num).replace(/\d/g, (d) => persianDigits[parseInt(d, 10)]);
}
/**
* Format a number with locale-aware digit conversion
* @param num - The number to format
* @param locale - The locale ('fa' for Persian, 'en' for English)
* @returns Formatted string with appropriate digits
*/
export function formatNumber(num: number, locale: string): string {
const formatted = num.toString();
return locale === 'fa' ? toPersianNumber(formatted) : formatted;
}
/**
* Format a number with abbreviation (k, M) and locale-aware digits
* @param num - The number to format
* @param locale - The locale ('fa' for Persian, 'en' for English)
* @returns Abbreviated formatted string (e.g., "1.5k" or "۱.۵k")
*/
export function formatCompactNumber(num: number, locale: string): string {
let formatted: string;
if (num >= 1000000) {
formatted = (num / 1000000).toFixed(1) + 'M+';
} else if (num >= 1000) {
formatted = (num / 1000).toFixed(1) + 'k';
} else {
formatted = num.toString();
}
return locale === 'fa' ? toPersianNumber(formatted) : formatted;
}
/**
* Format a number with thousand separators and locale-aware digits
* @param num - The number to format
* @param locale - The locale ('fa' for Persian, 'en' for English)
* @returns Formatted string with thousand separators
*/
export function formatWithSeparators(num: number, locale: string): string {
const formatted = num.toLocaleString('en-US');
return locale === 'fa' ? toPersianNumber(formatted) : formatted;
}
/**
* Format a skill count for prompt text (always English, rounded to nearest 5000)
* Used in discovery prompts that users copy into CLAUDE.md
*/
export function formatPromptSkillCount(count: number): string {
const rounded = Math.floor(count / 5000) * 5000;
return `${rounded.toLocaleString('en-US')}+`;
}

View File

@@ -0,0 +1,280 @@
/**
* GitHub Token Manager for Web App
*
* Manages multiple GitHub tokens for rate limit rotation in Next.js API routes.
* Uses Redis for shared state across serverless function instances.
*
* Configuration:
* GITHUB_TOKEN=token1 # Single token (backward compatible)
* GITHUB_TOKENS=token1,token2,token3 # Multiple tokens for rotation
* GITHUB_TOKEN_NAMES=primary,backup1 # Optional custom names
*/
import { getRedis } from './cache';
const REDIS_KEY = 'github:tokens';
const REDIS_TTL = 3600; // 1 hour
export interface TokenInfo {
token: string;
name: string;
remaining: number;
reset: number; // Unix timestamp in seconds
limit: number;
lastUsed: number;
isExhausted: boolean;
}
interface TokenState {
tokens: TokenInfo[];
lastUpdated: number;
}
/**
* Parse GitHub tokens from environment variables
*/
function parseTokensFromEnv(): { token: string; name: string }[] {
// Try multi-token format first
const tokensEnv = process.env.GITHUB_TOKENS;
const namesEnv = process.env.GITHUB_TOKEN_NAMES;
if (tokensEnv) {
const tokens = tokensEnv.split(',').map((t) => t.trim()).filter(Boolean);
const names = namesEnv ? namesEnv.split(',').map((n) => n.trim()) : [];
return tokens.map((token, i) => ({
token,
name: names[i] || `token-${i + 1}`,
}));
}
// Fallback to single token (backward compatible)
const singleToken = process.env.GITHUB_TOKEN;
if (singleToken) {
return [{ token: singleToken, name: 'primary' }];
}
return [];
}
/**
* Get token state from Redis or initialize
*/
async function getTokenState(): Promise<TokenState | null> {
const redis = getRedis();
if (!redis) return null;
try {
const cached = await redis.get(REDIS_KEY);
if (cached) {
return JSON.parse(cached) as TokenState;
}
} catch (error) {
console.warn('Failed to get token state from Redis:', error);
}
return null;
}
/**
* Save token state to Redis
*/
async function saveTokenState(state: TokenState): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.set(REDIS_KEY, JSON.stringify(state), 'EX', REDIS_TTL);
} catch (error) {
console.warn('Failed to save token state to Redis:', error);
}
}
/**
* Initialize tokens with default values
*/
function initializeTokens(): TokenInfo[] {
const parsed = parseTokensFromEnv();
return parsed.map(({ token, name }) => ({
token,
name,
remaining: 5000, // Default authenticated limit
reset: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
limit: 5000,
lastUsed: 0,
isExhausted: false,
}));
}
/**
* Get the best available token
*/
export async function getBestToken(): Promise<string | null> {
const envTokens = parseTokensFromEnv();
if (envTokens.length === 0) {
return null;
}
// If only one token, just return it
if (envTokens.length === 1) {
return envTokens[0].token;
}
// Try to get state from Redis
let state = await getTokenState();
if (!state) {
// Initialize with default values
state = {
tokens: initializeTokens(),
lastUpdated: Date.now(),
};
await saveTokenState(state);
}
// Find token with highest remaining requests
const available = state.tokens
.filter((t) => !t.isExhausted)
.sort((a, b) => b.remaining - a.remaining);
if (available.length > 0) {
return available[0].token;
}
// All exhausted - check if any reset time has passed
const now = Math.floor(Date.now() / 1000);
const resetTokens = state.tokens.filter((t) => t.reset <= now);
if (resetTokens.length > 0) {
// Reset the exhausted flag for tokens that have reset
for (const t of resetTokens) {
t.isExhausted = false;
t.remaining = t.limit;
}
await saveTokenState(state);
return resetTokens[0].token;
}
// Return token with earliest reset time
const earliest = state.tokens.sort((a, b) => a.reset - b.reset)[0];
return earliest.token;
}
/**
* Update token stats after a GitHub API call
*/
export async function updateTokenStats(
token: string,
headers: Headers
): Promise<void> {
const envTokens = parseTokensFromEnv();
if (envTokens.length <= 1) return; // Skip for single token
const remaining = headers.get('x-ratelimit-remaining');
const reset = headers.get('x-ratelimit-reset');
const limit = headers.get('x-ratelimit-limit');
if (!remaining) return;
let state = await getTokenState();
if (!state) {
state = {
tokens: initializeTokens(),
lastUpdated: Date.now(),
};
}
const tokenInfo = state.tokens.find((t) => t.token === token);
if (!tokenInfo) return;
tokenInfo.remaining = parseInt(remaining, 10);
tokenInfo.isExhausted = tokenInfo.remaining < 10;
tokenInfo.lastUsed = Date.now();
if (reset) {
tokenInfo.reset = parseInt(reset, 10);
}
if (limit) {
tokenInfo.limit = parseInt(limit, 10);
}
state.lastUpdated = Date.now();
await saveTokenState(state);
// Log when running low (suppress eslint warning for monitoring)
if (tokenInfo.remaining % 500 === 0 || tokenInfo.isExhausted) {
// eslint-disable-next-line no-console
console.log(
`[GitHub Token: ${tokenInfo.name}] ${tokenInfo.remaining}/${tokenInfo.limit} requests remaining`
);
}
}
/**
* Get token status for debugging/monitoring
*/
export async function getTokenStatus(): Promise<{
total: number;
available: number;
tokens: Array<{
name: string;
remaining: number;
limit: number;
isExhausted: boolean;
resetAt: string;
}>;
} | null> {
const envTokens = parseTokensFromEnv();
if (envTokens.length === 0) {
return null;
}
const state = await getTokenState();
if (!state) {
return {
total: envTokens.length,
available: envTokens.length,
tokens: envTokens.map(({ name }) => ({
name,
remaining: 5000,
limit: 5000,
isExhausted: false,
resetAt: new Date(Date.now() + 3600000).toISOString(),
})),
};
}
return {
total: state.tokens.length,
available: state.tokens.filter((t) => !t.isExhausted).length,
tokens: state.tokens.map((t) => ({
name: t.name,
remaining: t.remaining,
limit: t.limit,
isExhausted: t.isExhausted,
resetAt: new Date(t.reset * 1000).toISOString(),
})),
};
}
/**
* Create headers for GitHub API request with best available token
*/
export async function getGitHubHeaders(): Promise<{
headers: Record<string, string>;
token: string | null;
}> {
const token = await getBestToken();
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'SkillHub-Web/1.0',
};
if (token) {
headers.Authorization = `token ${token}`;
}
return { headers, token };
}

263
apps/web/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,263 @@
import Redis from 'ioredis';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Redis client singleton (shared with cache.ts)
let redis: Redis | null = null;
function getRedis(): Redis | null {
if (redis) return redis;
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
return null;
}
try {
redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy: (times) => {
if (times > 3) return null;
return Math.min(times * 100, 3000);
},
lazyConnect: true,
});
redis.on('error', (err) => {
console.error('[RateLimit] Redis error:', err.message);
});
return redis;
} catch (error) {
console.error('[RateLimit] Failed to initialize Redis:', error);
return null;
}
}
/**
* Rate limit configuration
* Liberal limits as requested by user
*/
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per window
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
// Liberal limits for general API access
anonymous: {
windowMs: 60 * 1000, // 1 minute
maxRequests: 120, // 120 req/min
},
authenticated: {
windowMs: 60 * 1000, // 1 minute
maxRequests: 600, // 600 req/min
},
// More restrictive for search (expensive operation)
search: {
windowMs: 60 * 1000, // 1 minute
maxRequests: 60, // 60 req/min
},
};
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
limit: number;
}
/**
* Check rate limit using sliding window algorithm
*/
export async function checkRateLimit(
identifier: string,
type: keyof typeof RATE_LIMITS = 'anonymous'
): Promise<RateLimitResult> {
const client = getRedis();
const config = RATE_LIMITS[type];
// If Redis unavailable, allow request (fail open)
if (!client) {
return {
allowed: true,
remaining: config.maxRequests,
resetAt: 0,
limit: config.maxRequests,
};
}
const key = `ratelimit:${type}:${identifier}`;
const now = Date.now();
const windowStart = now - config.windowMs;
try {
// Use pipeline for atomic operations
const pipeline = client.pipeline();
// Remove old entries outside the window
pipeline.zremrangebyscore(key, 0, windowStart);
// Count remaining entries
pipeline.zcard(key);
// Get the oldest entry timestamp (for reset time calculation)
pipeline.zrange(key, 0, 0, 'WITHSCORES');
const results = await pipeline.exec();
if (!results) {
return {
allowed: true,
remaining: config.maxRequests,
resetAt: 0,
limit: config.maxRequests,
};
}
const count = results[1]?.[1] as number || 0;
// Check if over limit
if (count >= config.maxRequests) {
const oldestEntry = results[2]?.[1] as string[] || [];
const oldestTimestamp = oldestEntry[1] ? parseInt(oldestEntry[1]) : now;
return {
allowed: false,
remaining: 0,
resetAt: oldestTimestamp + config.windowMs,
limit: config.maxRequests,
};
}
// Add current request to the window
await client.zadd(key, now, `${now}:${Math.random()}`);
await client.expire(key, Math.ceil(config.windowMs / 1000) + 1);
return {
allowed: true,
remaining: config.maxRequests - count - 1,
resetAt: now + config.windowMs,
limit: config.maxRequests,
};
} catch (error) {
console.error('[RateLimit] Error checking rate limit:', error);
// Fail open on errors
return {
allowed: true,
remaining: config.maxRequests,
resetAt: 0,
limit: config.maxRequests,
};
}
}
/**
* Get client identifier from request
* Uses X-Forwarded-For header or falls back to a default
*/
export function getClientIdentifier(request: NextRequest): string {
// Check for forwarded IP (behind proxy/load balancer)
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
// Check for real IP header
const realIp = request.headers.get('x-real-ip');
if (realIp) {
return realIp;
}
// Fall back to a hash of other identifying headers
const userAgent = request.headers.get('user-agent') || 'unknown';
return `ua:${hashString(userAgent)}`;
}
/**
* Simple hash function for fallback identification
*/
function hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
/**
* Create rate limit response headers
*/
export function createRateLimitHeaders(result: RateLimitResult): Record<string, string> {
return {
'X-RateLimit-Limit': result.limit.toString(),
'X-RateLimit-Remaining': Math.max(0, result.remaining).toString(),
'X-RateLimit-Reset': Math.ceil(result.resetAt / 1000).toString(),
};
}
/**
* Create 429 Too Many Requests response
* @param result - Rate limit result
* @param type - Rate limit type (for message customization)
* @param isAuthenticated - Whether user is logged in
*/
export function createRateLimitResponse(
result: RateLimitResult,
type: keyof typeof RATE_LIMITS = 'anonymous',
isAuthenticated: boolean = false
): NextResponse {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
// Customize message based on context
const message = `Rate limit exceeded (${result.limit} requests/minute). Please try again in ${retryAfter} seconds.`;
// Add helpful tips
let tip: string | undefined;
if (type === 'search') {
tip = 'Search operations are limited. Consider browsing categories instead.';
} else if (!isAuthenticated) {
tip = 'Sign in to increase your rate limit to 600 requests/minute.';
}
return NextResponse.json(
{
error: 'Too Many Requests',
message,
tip,
retryAfter,
limit: result.limit,
},
{
status: 429,
headers: {
...createRateLimitHeaders(result),
'Retry-After': Math.max(1, retryAfter).toString(),
},
}
);
}
/**
* Rate limit middleware wrapper for API routes
*
* Usage:
* ```typescript
* export async function GET(request: NextRequest) {
* const rateLimitResult = await withRateLimit(request, 'anonymous');
* if (!rateLimitResult.allowed) {
* return createRateLimitResponse(rateLimitResult);
* }
* // ... handle request
* }
* ```
*/
export async function withRateLimit(
request: NextRequest,
type: keyof typeof RATE_LIMITS = 'anonymous'
): Promise<RateLimitResult> {
const identifier = getClientIdentifier(request);
return checkRateLimit(identifier, type);
}

107
apps/web/lib/sanitize.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Input sanitization utilities for user-submitted content
* Server-side compatible - no DOM dependencies
*/
/**
* Strip all HTML tags from input
* Uses regex to remove all HTML elements
*
* @param input - Raw user input that may contain HTML
* @returns Plain text with all HTML tags removed
*/
export function stripHtml(input: string): string {
// Remove HTML tags
const result = input
// Remove script tags and their content
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// Remove style tags and their content
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
// Remove all other HTML tags
.replace(/<[^>]+>/g, '')
// Decode common HTML entities
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&nbsp;/g, ' ');
return result;
}
/**
* Sanitize text input with basic cleaning
* - Trims whitespace
* - Removes null bytes
* - Normalizes whitespace (multiple spaces to single)
* - Removes control characters except newlines and tabs
*
* @param input - Raw user input
* @returns Cleaned text string
*/
export function sanitizeText(input: string | null | undefined): string {
if (!input) {
return '';
}
return input
// Remove null bytes
.replace(/\0/g, '')
// Remove control characters except newlines (\n), carriage returns (\r), and tabs (\t)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
// Normalize multiple whitespace to single space (preserve newlines)
.replace(/[^\S\n]+/g, ' ')
// Trim leading/trailing whitespace
.trim();
}
/**
* Sanitize HTML input by stripping all HTML tags
* Returns plain text only - no HTML allowed
*
* @param input - Raw user input that may contain HTML
* @returns Sanitized string with all HTML tags removed
*/
export function sanitizeHtml(input: string | null | undefined): string {
if (!input) {
return '';
}
const stripped = stripHtml(input);
return sanitizeText(stripped);
}
/**
* Sanitize user-submitted review/comment text
* Combines HTML stripping and text cleaning
*
* @param input - Raw review text from user
* @returns Sanitized review text safe for storage and display
*/
export function sanitizeReview(input: string | null | undefined): string | null {
if (!input) {
return null;
}
const cleaned = sanitizeHtml(input);
// Return null if empty after sanitization
return cleaned.length > 0 ? cleaned : null;
}
/**
* Sanitize a reason field (for removal/add requests)
* Similar to review but always returns a string (required field)
*
* @param input - Raw reason text from user
* @returns Sanitized reason text
*/
export function sanitizeReason(input: string | null | undefined): string {
if (!input) {
return '';
}
return sanitizeHtml(input);
}

210
apps/web/lib/sentry.ts Normal file
View File

@@ -0,0 +1,210 @@
/**
* Sentry Helper Functions
* Centralized utilities for error tracking and performance monitoring
*/
import * as Sentry from "@sentry/nextjs";
// Re-export Sentry logger for structured logging
export const logger = Sentry.logger;
/**
* Capture an exception with additional context
*/
export function captureException(
error: Error | unknown,
context?: {
tags?: Record<string, string>;
extra?: Record<string, unknown>;
user?: { id?: string; email?: string; username?: string };
}
) {
Sentry.captureException(error, {
tags: context?.tags,
extra: context?.extra,
user: context?.user,
});
}
/**
* Capture a message (for non-error events)
*/
export function captureMessage(
message: string,
level: "fatal" | "error" | "warning" | "log" | "info" | "debug" = "info",
context?: Record<string, unknown>
) {
Sentry.captureMessage(message, {
level,
extra: context,
});
}
/**
* Set user context for all future events
*/
export function setUser(user: {
id: string;
email?: string;
username?: string;
} | null) {
Sentry.setUser(user);
}
/**
* Add a breadcrumb for debugging
*/
export function addBreadcrumb(
message: string,
category: string,
data?: Record<string, unknown>,
level: "fatal" | "error" | "warning" | "log" | "info" | "debug" = "info"
) {
Sentry.addBreadcrumb({
message,
category,
data,
level,
});
}
/**
* Start a performance span for tracking operations
*/
export async function withSpan<T>(
name: string,
operation: string,
callback: () => Promise<T>,
attributes?: Record<string, string | number | boolean>
): Promise<T> {
return Sentry.startSpan(
{
op: operation,
name,
attributes,
},
async (span) => {
try {
const result = await callback();
span.setStatus({ code: 1 }); // OK
return result;
} catch (error) {
span.setStatus({ code: 2, message: String(error) }); // ERROR
throw error;
}
}
);
}
/**
* Track API route performance
*/
export async function withApiSpan<T>(
routeName: string,
method: string,
callback: () => Promise<T>
): Promise<T> {
return withSpan(`${method} ${routeName}`, "http.server", callback, {
"http.method": method,
"http.route": routeName,
});
}
/**
* Track database query performance
*/
export async function withDbSpan<T>(
queryName: string,
callback: () => Promise<T>
): Promise<T> {
return withSpan(queryName, "db.query", callback);
}
/**
* Track external API call performance
*/
export async function withExternalApiSpan<T>(
apiName: string,
url: string,
callback: () => Promise<T>
): Promise<T> {
return withSpan(`${apiName}: ${url}`, "http.client", callback, {
"http.url": url,
});
}
/**
* Structured logging helpers with automatic Sentry integration
*/
export const log = {
trace: (message: string, data?: Record<string, unknown>) => {
logger.trace(message, data);
},
debug: (message: string, data?: Record<string, unknown>) => {
logger.debug(message, data);
},
info: (message: string, data?: Record<string, unknown>) => {
logger.info(message, data);
},
warn: (message: string, data?: Record<string, unknown>) => {
logger.warn(message, data);
},
error: (message: string, data?: Record<string, unknown>) => {
logger.error(message, data);
},
fatal: (message: string, data?: Record<string, unknown>) => {
logger.fatal(message, data);
},
};
/**
* API Error class for structured error handling
*/
export class APIError extends Error {
constructor(
public status: number,
message: string,
public code?: string
) {
super(message);
this.name = "APIError";
}
}
/**
* Handle and report API errors
*/
export function handleApiError(error: unknown, routeName: string): Response {
if (error instanceof APIError) {
// Expected API errors - log but don't report to Sentry
log.warn(`API Error in ${routeName}`, {
status: error.status,
message: error.message,
code: error.code,
});
return new Response(
JSON.stringify({ error: error.message, code: error.code }),
{ status: error.status }
);
}
// Unexpected errors - report to Sentry
captureException(error, {
tags: { route: routeName },
extra: { errorType: error instanceof Error ? error.name : typeof error },
});
log.error(`Unexpected error in ${routeName}`, {
error: error instanceof Error ? error.message : String(error),
});
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}