Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 기반 제목 생성, 스마트 검색, 리마인더 시스템을 제공합니다.

## 주요 기능
Expand Down
5 changes: 5 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
85 changes: 85 additions & 0 deletions supabase/utils/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
24 changes: 13 additions & 11 deletions supabase/utils/client.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
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;
if (typeof window !== 'undefined') {
debug = localStorage.getItem('debug')?.includes('auth') || false;
}
export function createClient() {
return createBrowserClient<Database>(
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<Database>(config.url, config.anonKey, {
auth: {
debug: process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED
? process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED === 'true'
: false,
},
});
}

// 쿠키 정보를 안전하게 가져오는 함수
Expand Down
25 changes: 25 additions & 0 deletions supabase/utils/config.ts
Original file line number Diff line number Diff line change
@@ -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.';
106 changes: 55 additions & 51 deletions supabase/utils/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 });
Expand All @@ -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 {
Expand Down
16 changes: 11 additions & 5 deletions supabase/utils/server.ts
Original file line number Diff line number Diff line change
@@ -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<Database>(url, key, {
return createServerClient<Database>(resolvedUrl, resolvedKey, {
auth: {
debug: process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED
? process.env.NEXT_PUBLIC_SUPABASE_AUTH_DEBUG_ENABLED === 'true'
Expand Down
15 changes: 11 additions & 4 deletions supabase/utils/super.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Database>(
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<Database>(config.url, serviceRoleKey);
}
Loading