From e98791fed92fa92804b332bbe98a2a07e8af5d40 Mon Sep 17 00:00:00 2001 From: egoing Date: Wed, 25 Mar 2026 18:33:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Issue=20#80=20-=20Vercel=20Deploy?= =?UTF-8?q?=20Button=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase Integration(oac_VqOgBHqhEoFTPzGkPd7L0iH6)을 통해 Supabase 환경변수 자동 설정 및 마이그레이션 자동 실행. Uploadcare/OpenAI 키는 선택적 수동 입력. https://github.com/opentutorials-org/otu.oss/issues/80 Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 228f4e6..fc22f6b 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)]() + **OTU**는 웹과 모바일을 지원하는 차세대 AI 메모 애플리케이션입니다. BlockNote 에디터와 OpenAI GPT-4o를 활용하여 자동 저장, AI 기반 제목 생성, 스마트 검색, 리마인더 시스템을 제공합니다. ## 주요 기능 From 04fdb0585f3968c4957c8615f6c775280b2383ab Mon Sep 17 00:00:00 2001 From: egoing Date: Wed, 25 Mar 2026 19:05:30 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20Issue=20#80=20-=20Deploy=20Button=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deploy URL에서 Uploadcare/OpenAI 환경변수 입력 단계 제거 - next.config.js에 Supabase 환경변수 매핑 추가 (SUPABASE_URL → NEXT_PUBLIC_SUPABASE_URL) (SUPABASE_PUBLISHABLE_KEY → NEXT_PUBLIC_SUPABASE_ANON_KEY) https://github.com/opentutorials-org/otu.oss/issues/80 Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- next.config.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fc22f6b..7e6f073 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![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)]() +[![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) From fe43b1066e0a92e7546217711c53d8f2324080f4 Mon Sep 17 00:00:00 2001 From: egoing Date: Wed, 25 Mar 2026 21:30:30 +0900 Subject: [PATCH 3/3] fix: graceful handling when Supabase env vars are not configured When deploying via Vercel Deploy Button, Supabase Integration may not be connected yet, leaving env vars unset. Previously the app crashed with "@supabase/ssr: Your project's URL and API key are required". - Add supabase/utils/config.ts with shared env var check helpers - middleware.ts: skip updateSession when Supabase is not configured - client.ts, server.ts, super.ts: throw descriptive error instead of passing undefined via non-null assertion - Add unit tests for config helpers Co-Authored-By: Claude Opus 4.6 (1M context) --- supabase/utils/__tests__/config.test.ts | 85 +++++++++++++++++++ supabase/utils/client.ts | 24 +++--- supabase/utils/config.ts | 25 ++++++ supabase/utils/middleware.ts | 106 ++++++++++++------------ supabase/utils/server.ts | 16 ++-- supabase/utils/super.ts | 15 +++- 6 files changed, 200 insertions(+), 71 deletions(-) create mode 100644 supabase/utils/__tests__/config.test.ts create mode 100644 supabase/utils/config.ts 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); }