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:
105
apps/web/lib/auth.ts
Normal file
105
apps/web/lib/auth.ts
Normal 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
224
apps/web/lib/cache.ts
Normal 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
114
apps/web/lib/csrf-client.ts
Normal 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
63
apps/web/lib/csrf.ts
Normal 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 };
|
||||
438
apps/web/lib/email-templates.ts
Normal file
438
apps/web/lib/email-templates.ts
Normal 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};">
|
||||
© ${new Date().getFullYear()} SkillHub —
|
||||
<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' ? '💡 Tip' : '💡 نکته'}
|
||||
</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 <skill-id> --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} →
|
||||
</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;">
|
||||
💡 ${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 "your topic"</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 →</a>`
|
||||
: `<strong>نکته:</strong> مهارت\u200Cها وقتی بهترین عملکرد را دارند که صریحاً فراخوانی شوند. جستجو و نصب لحظه\u200Cای را امتحان کنید: <code style="background: ${COLORS.surface}; padding: 1px 4px; border-radius: 3px; font-size: 12px;" dir="ltr">npx skillhub search "your topic"</code>. پس از نصب، فایل SKILL.md را بخوانید و دستورالعمل\u200Cها را دنبال کنید. <a href="${SITE_URL}/${locale}/docs/cli" style="color: ${COLORS.primary}; text-decoration: none;">مستندات CLI →</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
179
apps/web/lib/email.ts
Normal 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
94
apps/web/lib/env.ts
Normal 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;
|
||||
}
|
||||
64
apps/web/lib/format-number.ts
Normal file
64
apps/web/lib/format-number.ts
Normal 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')}+`;
|
||||
}
|
||||
280
apps/web/lib/github-token-manager.ts
Normal file
280
apps/web/lib/github-token-manager.ts
Normal 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
263
apps/web/lib/rate-limit.ts
Normal 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
107
apps/web/lib/sanitize.ts
Normal 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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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
210
apps/web/lib/sentry.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user