diff --git a/README.md b/README.md index 228f4e6..7e6f073 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ [![React](https://img.shields.io/badge/React-19-blue?logo=react)](https://react.dev/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue?logo=typescript)](https://www.typescriptlang.org/) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fopentutorials-org%2Fotu.oss&project-name=otu&repository-name=otu&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fopentutorials-org%2Fotu.oss%2Ftree%2Fmain) + **OTU**는 웹과 모바일을 지원하는 차세대 AI 메모 애플리케이션입니다. BlockNote 에디터와 OpenAI GPT-4o를 활용하여 자동 저장, AI 기반 제목 생성, 스마트 검색, 리마인더 시스템을 제공합니다. ## 주요 기능 diff --git a/next.config.js b/next.config.js index bade454..6d4cd8a 100644 --- a/next.config.js +++ b/next.config.js @@ -8,6 +8,11 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ /** @type {import('next').NextConfig} */ const nextConfig = { + env: { + NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL, + NEXT_PUBLIC_SUPABASE_ANON_KEY: + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_PUBLISHABLE_KEY, + }, images: { remotePatterns: [ // Uploadcare legacy global domain (accounts before Sept 4, 2025) diff --git a/supabase/utils/__tests__/config.test.ts b/supabase/utils/__tests__/config.test.ts new file mode 100644 index 0000000..18208cc --- /dev/null +++ b/supabase/utils/__tests__/config.test.ts @@ -0,0 +1,85 @@ +/** + * @jest-environment node + */ +import { + getSupabaseConfig, + isSupabaseConfigured, + SUPABASE_NOT_CONFIGURED_MESSAGE, +} from '../config'; + +describe('supabase/utils/config', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('getSupabaseConfig', () => { + it('should return config when both env vars are set', () => { + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key'; + + const config = getSupabaseConfig(); + expect(config).toEqual({ + url: 'https://test.supabase.co', + anonKey: 'test-anon-key', + }); + }); + + it('should return null when NEXT_PUBLIC_SUPABASE_URL is missing', () => { + delete process.env.NEXT_PUBLIC_SUPABASE_URL; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key'; + + expect(getSupabaseConfig()).toBeNull(); + }); + + it('should return null when NEXT_PUBLIC_SUPABASE_ANON_KEY is missing', () => { + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'; + delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + expect(getSupabaseConfig()).toBeNull(); + }); + + it('should return null when both env vars are missing', () => { + delete process.env.NEXT_PUBLIC_SUPABASE_URL; + delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + expect(getSupabaseConfig()).toBeNull(); + }); + + it('should return null when env vars are empty strings', () => { + process.env.NEXT_PUBLIC_SUPABASE_URL = ''; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = ''; + + expect(getSupabaseConfig()).toBeNull(); + }); + }); + + describe('isSupabaseConfigured', () => { + it('should return true when configured', () => { + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key'; + + expect(isSupabaseConfigured()).toBe(true); + }); + + it('should return false when not configured', () => { + delete process.env.NEXT_PUBLIC_SUPABASE_URL; + delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + expect(isSupabaseConfigured()).toBe(false); + }); + }); + + describe('SUPABASE_NOT_CONFIGURED_MESSAGE', () => { + it('should contain guidance about required env vars', () => { + expect(SUPABASE_NOT_CONFIGURED_MESSAGE).toContain('NEXT_PUBLIC_SUPABASE_URL'); + expect(SUPABASE_NOT_CONFIGURED_MESSAGE).toContain('NEXT_PUBLIC_SUPABASE_ANON_KEY'); + }); + }); +}); diff --git a/supabase/utils/client.ts b/supabase/utils/client.ts index 1fbc137..614a933 100644 --- a/supabase/utils/client.ts +++ b/supabase/utils/client.ts @@ -1,6 +1,7 @@ import { Database } from '@/lib/database/types'; import { createBrowserClient } from '@supabase/ssr'; import { createServerClient } from '@supabase/ssr'; +import { getSupabaseConfig, SUPABASE_NOT_CONFIGURED_MESSAGE } from './config'; // @ts-ignore let debug = false; @@ -8,17 +9,18 @@ if (typeof window !== 'undefined') { debug = localStorage.getItem('debug')?.includes('auth') || false; } export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - auth: { - debug: process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED - ? process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED === 'true' - : false, - }, - } - ); + const config = getSupabaseConfig(); + if (!config) { + throw new Error(SUPABASE_NOT_CONFIGURED_MESSAGE); + } + + return createBrowserClient(config.url, config.anonKey, { + auth: { + debug: process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED + ? process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED === 'true' + : false, + }, + }); } // 쿠키 정보를 안전하게 가져오는 함수 diff --git a/supabase/utils/config.ts b/supabase/utils/config.ts new file mode 100644 index 0000000..3a9a734 --- /dev/null +++ b/supabase/utils/config.ts @@ -0,0 +1,25 @@ +/** + * Supabase 환경변수 설정 확인 유틸리티 + * + * Vercel Deploy Button으로 배포할 때 Supabase Integration이 아직 연결되지 않으면 + * 환경변수가 미설정 상태일 수 있습니다. 이 경우 앱이 크래시하지 않도록 + * graceful하게 처리합니다. + */ + +export function getSupabaseConfig(): { url: string; anonKey: string } | null { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!url || !anonKey) { + return null; + } + + return { url, anonKey }; +} + +export function isSupabaseConfigured(): boolean { + return getSupabaseConfig() !== null; +} + +export const SUPABASE_NOT_CONFIGURED_MESSAGE = + 'Supabase is not configured. Please set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.'; diff --git a/supabase/utils/middleware.ts b/supabase/utils/middleware.ts index 81bf58b..ae5d5f1 100644 --- a/supabase/utils/middleware.ts +++ b/supabase/utils/middleware.ts @@ -6,6 +6,7 @@ import { authLogger } from '@/debug/auth'; import { utf8Logger, cookieLogger } from '@/debug/middleware'; import { cookies } from 'next/headers'; import { reportValue } from '@vercel/flags'; +import { getSupabaseConfig } from './config'; function getUserIp(request: NextRequest): string { const xForwardedFor = request.headers.get('x-forwarded-for'); @@ -132,6 +133,13 @@ function optimizeTokenFragments( } export async function updateSession(request: NextRequest) { + const config = getSupabaseConfig(); + if (!config) { + // Supabase 환경변수가 설정되지 않은 경우 세션 처리를 건너뛰고 + // 요청을 그대로 통과시킵니다. + return NextResponse.next({ request }); + } + const debug = process.env.DEBUG?.includes('auth') || false; const cookieStore = await cookies(); authLogger('updateSession', { path: request.nextUrl.pathname }); @@ -144,59 +152,55 @@ export async function updateSession(request: NextRequest) { request, }); - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - auth: { - debug: process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED - ? process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED === 'true' - : false, + const supabase = createServerClient(config.url, config.anonKey, { + auth: { + debug: process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED + ? process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED === 'true' + : false, + }, + cookies: { + getAll() { + try { + const rawData = request.cookies.getAll(); + cookieLogger('getAll 시작 - 원본 쿠키 개수:', rawData.length); + + // 토큰 조각 최적화 적용 (결과를 요청 스코프 변수에 저장) + const result = optimizeTokenFragments(rawData); + invalidFragmentNamesToDelete = result.invalidFragmentNames; + cookieLogger('getAll - 최적화 후 쿠키 개수:', result.cookies.length); + + authLogger('getAll', result.cookies); + cookieLogger('getAll 완료 - 최적화된 쿠키 반환'); + return result.cookies; + } catch (error) { + utf8Logger('getAll에서 치명적 오류 발생:', error); + cookieLogger('getAll 오류 상세:', error); + throw error; + } }, - cookies: { - getAll() { - try { - const rawData = request.cookies.getAll(); - cookieLogger('getAll 시작 - 원본 쿠키 개수:', rawData.length); - - // 토큰 조각 최적화 적용 (결과를 요청 스코프 변수에 저장) - const result = optimizeTokenFragments(rawData); - invalidFragmentNamesToDelete = result.invalidFragmentNames; - cookieLogger('getAll - 최적화 후 쿠키 개수:', result.cookies.length); - - authLogger('getAll', result.cookies); - cookieLogger('getAll 완료 - 최적화된 쿠키 반환'); - return result.cookies; - } catch (error) { - utf8Logger('getAll에서 치명적 오류 발생:', error); - cookieLogger('getAll 오류 상세:', error); - throw error; - } - }, - setAll(cookiesToSet) { - try { - cookieLogger('setAll 시작 - 설정할 쿠키 개수:', cookiesToSet.length); - - authLogger('setAll', cookiesToSet); - cookiesToSet.forEach(({ name, value, options }) => - request.cookies.set(name, value) - ); - supabaseResponse = NextResponse.next({ - request, - }); - cookiesToSet.forEach(({ name, value, options }) => { - return supabaseResponse.cookies.set(name, value, options); - }); - cookieLogger('setAll 완료'); - } catch (error) { - utf8Logger('setAll에서 치명적 오류 발생:', error); - cookieLogger('setAll 오류 상세:', error); - throw error; - } - }, + setAll(cookiesToSet) { + try { + cookieLogger('setAll 시작 - 설정할 쿠키 개수:', cookiesToSet.length); + + authLogger('setAll', cookiesToSet); + cookiesToSet.forEach(({ name, value, options }) => + request.cookies.set(name, value) + ); + supabaseResponse = NextResponse.next({ + request, + }); + cookiesToSet.forEach(({ name, value, options }) => { + return supabaseResponse.cookies.set(name, value, options); + }); + cookieLogger('setAll 완료'); + } catch (error) { + utf8Logger('setAll에서 치명적 오류 발생:', error); + cookieLogger('setAll 오류 상세:', error); + throw error; + } }, - } - ); + }, + }); // 🔥 getUser 호출 시 UTF-8 오류 처리 추가 let user; try { diff --git a/supabase/utils/server.ts b/supabase/utils/server.ts index 62abbea..1472445 100644 --- a/supabase/utils/server.ts +++ b/supabase/utils/server.ts @@ -1,14 +1,20 @@ import { Database } from '@/lib/database/types'; import { createServerClient, type CookieOptions } from '@supabase/ssr'; import { cookies } from 'next/headers'; +import { getSupabaseConfig, SUPABASE_NOT_CONFIGURED_MESSAGE } from './config'; + +export async function createClient(url?: string, key?: string) { + const config = getSupabaseConfig(); + const resolvedUrl = url ?? config?.url; + const resolvedKey = key ?? config?.anonKey; + + if (!resolvedUrl || !resolvedKey) { + throw new Error(SUPABASE_NOT_CONFIGURED_MESSAGE); + } -export async function createClient( - url = process.env.NEXT_PUBLIC_SUPABASE_URL!, - key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! -) { const cookieStore = await cookies(); - return createServerClient(url, key, { + return createServerClient(resolvedUrl, resolvedKey, { auth: { debug: process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED ? process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED === 'true' diff --git a/supabase/utils/super.ts b/supabase/utils/super.ts index c7aa3b0..617d2dd 100644 --- a/supabase/utils/super.ts +++ b/supabase/utils/super.ts @@ -2,10 +2,17 @@ import { Database } from '@/lib/database/types'; import { createServerClient, type CookieOptions } from '@supabase/ssr'; import { createClient } from '@supabase/supabase-js'; import { cookies } from 'next/headers'; +import { getSupabaseConfig } from './config'; export function createSuperClient() { - return createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! - ); + const config = getSupabaseConfig(); + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!config || !serviceRoleKey) { + throw new Error( + 'Supabase is not configured. Please set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables.' + ); + } + + return createClient(config.url, serviceRoleKey); }