///
const SUPPORTED_LOCALES = ['en', 'zh', 'es', 'fr', 'de', 'ja', 'ru', 'ko', 'pt'] as const
interface QuizRecordRequest {
quizId: string
pagePath: string
userId?: number
}
interface UserInfo {
id?: number
name?: string
nick_name?: string
email?: string
[key: string]: unknown
}
interface UserData {
user?: UserInfo
[key: string]: unknown
}
/**
* Normalize path to English version by removing language prefix
* This ensures all language versions of the same page share the same quiz data
*/
function normalizePathToEnglish(path: string): string {
const segments = path.split('/').filter(Boolean)
if (segments.length > 0 && SUPPORTED_LOCALES.includes(segments[0] as typeof SUPPORTED_LOCALES[number])) {
segments.shift()
return segments.length > 0 ? '/' + segments.join('/') : '/'
}
return path
}
export default {
async fetch(request: Request, env: { PYTHONCHEATSHEET_QUIZ_KV: KVNamespace; ASSETS?: { fetch: (_req: Request) => Promise } }): Promise {
const url = new URL(request.url)
// Handle redirects for custom domains
if (url.hostname === 'pythoncheatsheet.org' || url.hostname === 'www.pythoncheatsheet.org') {
const targetPath = url.pathname === '/' ? '' : url.pathname
const targetUrl = `https://labex.io/pythoncheatsheet${targetPath}${url.search}`
return Response.redirect(targetUrl, 301)
}
// Handle API routes
if (url.pathname.startsWith('/pythoncheatsheet/api/')) {
return handleAPI(request, env)
}
// For all other requests, let Cloudflare handle static assets
// If ASSETS is available (for Workers with assets), use it; otherwise pass through
if (env.ASSETS) {
return env.ASSETS.fetch(request)
}
// Fallback: return the request as-is (Cloudflare will handle it)
return fetch(request)
},
}
async function handleAPI(request: Request, env: { PYTHONCHEATSHEET_QUIZ_KV: KVNamespace }): Promise {
const url = new URL(request.url)
// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Credentials': 'true',
},
})
}
// Handle POST /pythoncheatsheet/api/quiz/record
if (url.pathname === '/pythoncheatsheet/api/quiz/record' && request.method === 'POST') {
return handleRecordQuiz(request, env)
}
// Handle GET /pythoncheatsheet/api/quiz/stats
if (url.pathname === '/pythoncheatsheet/api/quiz/stats' && request.method === 'GET') {
return handleGetStats(request, env)
}
// Handle GET /pythoncheatsheet/api/quiz/user-status
if (url.pathname === '/pythoncheatsheet/api/quiz/user-status' && request.method === 'GET') {
return handleGetUserStatus(request, env)
}
// Handle GET /pythoncheatsheet/api/user/me
if (url.pathname === '/pythoncheatsheet/api/user/me' && request.method === 'GET') {
return handleUserMe(request, env)
}
return new Response(
JSON.stringify({ error: 'Not found' }),
{
status: 404,
headers: { 'Content-Type': 'application/json' },
}
)
}
async function getUserFromCookies(request: Request, env: { PYTHONCHEATSHEET_QUIZ_KV: KVNamespace }): Promise {
try {
const cookies = request.headers.get('Cookie')
if (!cookies) {
return null
}
// Hash the cookies to use as a cache key
const encoder = new TextEncoder()
const data = encoder.encode(cookies)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
// Key format: pythoncheatsheet:session:${hashHex}
const cacheKey = `pythoncheatsheet:session:${hashHex}`
// Try to get from KV first
const cachedUser = await env.PYTHONCHEATSHEET_QUIZ_KV.get(cacheKey)
if (cachedUser) {
try {
return JSON.parse(cachedUser) as UserInfo
} catch (e) {
console.error('Error parsing cached user:', e)
}
}
const response = await fetch('https://labex.io/api/v2/users/me', {
method: 'GET',
headers: {
'Cookie': cookies,
'User-Agent': request.headers.get('User-Agent') || 'Cloudflare Worker',
'Content-Type': 'application/json',
},
})
if (response.ok) {
const userData = await response.json() as UserData
const user = userData.user || null
if (user) {
// Cache the user info in KV with a 10-minute expiration
// We only cache if we got a valid user
await env.PYTHONCHEATSHEET_QUIZ_KV.put(cacheKey, JSON.stringify(user), { expirationTtl: 600 })
}
return user
}
return null
} catch (error) {
console.error('Error fetching user from cookies:', error)
return null
}
}
async function handleRecordQuiz(request: Request, env: { PYTHONCHEATSHEET_QUIZ_KV: KVNamespace }): Promise {
try {
const body = await request.json() as QuizRecordRequest
const { quizId, pagePath } = body
if (!quizId || !pagePath) {
return new Response(
JSON.stringify({ error: 'Missing required fields: quizId and pagePath' }),
{
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
}
)
}
const normalizedPath = normalizePathToEnglish(pagePath)
// Generate unique key: combine pythoncheatsheet prefix, normalized pagePath and quizId
// Format: pythoncheatsheet:quiz:${normalizedPath}:${quizId}
const key = `pythoncheatsheet:quiz:${normalizedPath}:${quizId}`
// Get current count from KV
const currentCount = await env.PYTHONCHEATSHEET_QUIZ_KV.get(key)
const count = currentCount ? parseInt(currentCount, 10) : 0
// Increment count
const newCount = count + 1
// Save to KV
await env.PYTHONCHEATSHEET_QUIZ_KV.put(key, newCount.toString())
// Record user completion status if user is authenticated
const user = await getUserFromCookies(request, env)
if (user && user.id) {
// Format: pythoncheatsheet:user-quiz:${userId}:${normalizedPath}:${quizId}
const userKey = `pythoncheatsheet:user-quiz:${user.id}:${normalizedPath}:${quizId}`
await env.PYTHONCHEATSHEET_QUIZ_KV.put(userKey, 'true')
}
return new Response(
JSON.stringify({
success: true,
quizId,
pagePath: normalizedPath,
count: newCount,
...(user && user.id && { userId: user.id }),
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
} catch (error) {
console.error('Error recording quiz completion:', error)
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
}
}
async function handleGetStats(request: Request, env: { PYTHONCHEATSHEET_QUIZ_KV: KVNamespace }): Promise {
try {
const url = new URL(request.url)
const quizId = url.searchParams.get('quizId')
const pagePath = url.searchParams.get('pagePath')
if (!quizId || !pagePath) {
return new Response(
JSON.stringify({ error: 'Missing required query parameters: quizId and pagePath' }),
{
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
}
)
}
const normalizedPath = normalizePathToEnglish(pagePath)
// Generate unique key with pythoncheatsheet prefix
// Format: pythoncheatsheet:quiz:${normalizedPath}:${quizId}
const key = `pythoncheatsheet:quiz:${normalizedPath}:${quizId}`
const countStr = await env.PYTHONCHEATSHEET_QUIZ_KV.get(key)
const count = countStr ? parseInt(countStr, 10) : 0
return new Response(
JSON.stringify({
success: true,
quizId,
pagePath: normalizedPath,
count,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'public, max-age=60, s-maxage=60',
},
}
)
} catch (error) {
console.error('Error fetching quiz stats:', error)
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
}
}
async function handleGetUserStatus(request: Request, env: { PYTHONCHEATSHEET_QUIZ_KV: KVNamespace }): Promise {
try {
const url = new URL(request.url)
const quizId = url.searchParams.get('quizId')
const pagePath = url.searchParams.get('pagePath')
if (!quizId || !pagePath) {
return new Response(
JSON.stringify({ error: 'Missing required query parameters: quizId and pagePath' }),
{
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
}
)
}
// Get user from cookies using LabEx API
const user = await getUserFromCookies(request, env)
if (!user || !user.id) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{
status: 401,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
}
)
}
const userId = user.id
const normalizedPath = normalizePathToEnglish(pagePath)
// Generate unique key for user completion status with pythoncheatsheet prefix
// Format: pythoncheatsheet:user-quiz:${userId}:${normalizedPath}:${quizId}
const key = `pythoncheatsheet:user-quiz:${userId}:${normalizedPath}:${quizId}`
const completedStr = await env.PYTHONCHEATSHEET_QUIZ_KV.get(key)
const completed = completedStr === 'true'
return new Response(
JSON.stringify({
success: true,
quizId,
pagePath: normalizedPath,
userId,
completed,
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'private, no-cache, must-revalidate',
},
}
)
} catch (error) {
console.error('Error fetching user quiz status:', error)
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
}
)
}
}
async function handleUserMe(request: Request, env: { PYTHONCHEATSHEET_QUIZ_KV: KVNamespace }): Promise {
try {
const user = await getUserFromCookies(request, env)
if (!user) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{
status: 401,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': 'true',
},
}
)
}
return new Response(
JSON.stringify({ user }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': 'true',
},
}
)
} catch (error) {
console.error('Error in handleUserMe:', error)
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': 'true',
},
}
)
}
}