diff --git a/.env.example b/.env.example index 16cc98d..71fe8a7 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,10 @@ GEMINI_API_KEY=your_gemini_api_key_here # JWT auth JWT_SECRET="local-dev-secret-change-me" +# iCloud 캘린더 앱 전용 암호 암호화 키 (AES-256-GCM) +# 64자리 hex. 생성: openssl rand -hex 32 +ENCRYPTION_SECRET=your_64_hex_encryption_secret_here + # App base URL NEXT_PUBLIC_BASE_URL=http://localhost:3000 diff --git a/src/app/api/calendar/status/route.ts b/src/app/api/calendar/status/route.ts index a1a1804..9ab9f36 100644 --- a/src/app/api/calendar/status/route.ts +++ b/src/app/api/calendar/status/route.ts @@ -1,47 +1,32 @@ import { NextResponse } from "next/server"; -import { getSession } from "@/lib/auth/session"; -import { createClient } from "@/lib/supabase/server"; +import { cookies } from "next/headers"; +import { getConnection } from "@/lib/caldav/connection-cookie"; export const dynamic = "force-dynamic"; +/** + * GET /api/calendar/status + * 현재 브라우저의 캘린더 연동 상태를 반환한다. + * + * Google/Naver와 동일하게 연동 정보는 HttpOnly 쿠키에 저장되므로, 별도의 앱 + * 세션 검증 없이 쿠키 존재 여부로 상태를 판단한다. + */ export async function GET() { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 }); - } - try { - const supabase = await createClient(); - - // 1. Google 연동 확인 - const { data: googleConn, error: googleError } = await supabase - .from("google_connections") - .select("google_email") - .eq("profile_id", session.userId) - .eq("is_active", true) - .maybeSingle(); - - if (googleError) { - console.error("[calendar.status] Google 조회 실패:", googleError.message); - } + const cookieStore = await cookies(); - // 2. iCloud 연동 확인 - const { data: icloudConn, error: icloudError } = await supabase - .from("icloud_connections") - .select("apple_id") - .eq("profile_id", session.userId) - .eq("is_active", true) - .maybeSingle(); + // 1. Google 연동 확인 (google/auth.ts의 google_tokens 쿠키) + const googleConnected = !!cookieStore.get("google_tokens")?.value; - if (icloudError) { - console.error("[calendar.status] iCloud 조회 실패:", icloudError.message); - } + // 2. iCloud 연동 확인 (connection-cookie.ts의 icloud_connection 쿠키) + const icloud = await getConnection(); return NextResponse.json({ - googleConnected: !!googleConn, - googleEmail: googleConn?.google_email ?? undefined, - icloudConnected: !!icloudConn, - icloudAppleId: icloudConn?.apple_id ?? undefined, + googleConnected, + // 쿠키 저장 방식에는 Google 이메일이 없으므로 미제공 + googleEmail: undefined, + icloudConnected: !!icloud, + icloudAppleId: icloud?.appleId ?? undefined, }); } catch (err) { console.error("[calendar.status] 오류 발생:", err); diff --git a/src/app/api/icloud/calendars/route.ts b/src/app/api/icloud/calendars/route.ts index fbcd92e..667c4e2 100644 --- a/src/app/api/icloud/calendars/route.ts +++ b/src/app/api/icloud/calendars/route.ts @@ -1,117 +1,37 @@ -import { NextRequest, NextResponse } from "next/server"; -import { requireSession, UnauthorizedError } from "@/lib/auth/session"; -import { decrypt, deserializeEncrypted, maskEmail } from "@/lib/crypto"; +import { NextResponse } from "next/server"; +import { maskEmail } from "@/lib/crypto"; import { discoverCalDAV } from "@/lib/caldav/discovery"; import { CalDAVError } from "@/lib/caldav/client"; -import { createClient } from "@/lib/supabase/server"; -import type { ICloudConnectionRow, ICloudCalendarRow } from "@/types/icloud"; +import { getConnectionAuth } from "@/lib/caldav/connection-cookie"; export const dynamic = "force-dynamic"; -export async function GET(req: NextRequest) { - // ── 1. 인증 검증 ────────────────────────────────────────── - let session; - try { - session = await requireSession(); - } catch (e) { - if (e instanceof UnauthorizedError) { - return NextResponse.json( - { error: "인증이 필요합니다." }, - { status: 401 }, - ); - } - throw e; - } - - const connectionId = req.nextUrl.searchParams.get("connectionId"); - const supabase = await createClient(); - - // ── 2. 연결 정보 조회 ───────────────────────────────────── - let query = supabase - .from("icloud_connections") - .select("*") - .eq("profile_id", session.userId) - .eq("is_active", true); - - if (connectionId) { - query = query.eq("id", connectionId); - } - - const { data: rawConnection, error: connError } = await query.single(); - const connection = rawConnection as ICloudConnectionRow | null; - - if (connError || !connection) { +/** + * GET /api/icloud/calendars + * 연결된 iCloud 계정의 캘린더 컬렉션 목록을 CalDAV에서 실시간 조회한다. + */ +export async function GET() { + const connection = await getConnectionAuth(); + if (!connection) { return NextResponse.json( { error: "연결된 iCloud 계정이 없습니다. 먼저 계정을 연결해주세요." }, { status: 404 }, ); } - // ── 3. DB 캐시에서 캘린더 목록 우선 반환 ────────────────── - const { data: cachedCalendars } = await supabase - .from("icloud_calendars") - .select("*") - .eq("connection_id", connection.id) - .order("display_name"); - - // ctag를 비교해 캐시 유효성 체크할 수 있지만, - // MVP에서는 캐시가 있으면 그대로 반환한다. - if (cachedCalendars && cachedCalendars.length > 0) { - return NextResponse.json({ - calendars: (cachedCalendars as ICloudCalendarRow[]).map((c) => ({ - id: c.id, - displayName: c.display_name, - calendarUrl: c.calendar_url, - color: c.color, - })), - cached: true, - }); - } - - // ── 4. 캐시 없으면 CalDAV에서 실시간 조회 ───────────────── try { - const plainPassword = decrypt( - deserializeEncrypted( - connection.encrypted_password, - connection.encryption_iv, - ), - ); - const discovery = await discoverCalDAV({ - username: connection.apple_id, - password: plainPassword, + username: connection.appleId, + password: connection.password, }); - // ── 5. DB 캐시 갱신 ──────────────────────────────────── - if (discovery.calendars.length > 0) { - await supabase.from("icloud_calendars").upsert( - discovery.calendars.map((c) => ({ - connection_id: connection.id, - display_name: c.displayName, - calendar_url: c.url, - color: c.color ?? null, - ctag: c.ctag ?? null, - synced_at: new Date().toISOString(), - })), - { onConflict: "connection_id,calendar_url" }, - ); - } - - // 새로 저장된 캘린더를 id 포함해서 다시 조회 - const { data: freshCalendars } = await supabase - .from("icloud_calendars") - .select("*") - .eq("connection_id", connection.id) - .order("display_name"); - return NextResponse.json({ - calendars: ((freshCalendars as ICloudCalendarRow[]) ?? []).map((c) => ({ - id: c.id, - displayName: c.display_name, - calendarUrl: c.calendar_url, - color: c.color, + calendars: discovery.calendars.map((c) => ({ + // 쿠키 저장 방식에는 DB UUID가 없으므로 calendarUrl이 식별자 + calendarUrl: c.url, + displayName: c.displayName, + color: c.color ?? null, })), - cached: false, }); } catch (err) { if (err instanceof CalDAVError && err.statusCode === 401) { @@ -122,8 +42,7 @@ export async function GET(req: NextRequest) { } console.error("[icloud.calendars] 오류", { - userId: session.userId, - appleId: maskEmail(connection.apple_id), + appleId: maskEmail(connection.appleId), error: err instanceof Error ? err.message : "unknown", }); return NextResponse.json( diff --git a/src/app/api/icloud/connect/route.ts b/src/app/api/icloud/connect/route.ts index 9e708e5..2c577ac 100644 --- a/src/app/api/icloud/connect/route.ts +++ b/src/app/api/icloud/connect/route.ts @@ -1,11 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { requireSession, UnauthorizedError } from "@/lib/auth/session"; -import { encrypt, serializeEncrypted, maskEmail } from "@/lib/crypto"; +import { maskEmail } from "@/lib/crypto"; import { discoverCalDAV } from "@/lib/caldav/discovery"; import { CalDAVError } from "@/lib/caldav/client"; -import { createClient } from "@/lib/supabase/server"; -import type { CalendarInfo } from "@/types/icloud"; +import { saveConnection } from "@/lib/caldav/connection-cookie"; export const dynamic = "force-dynamic"; @@ -22,21 +20,7 @@ const ConnectSchema = z.object({ }); export async function POST(req: NextRequest) { - // ── 1. 인증 검증 ────────────────────────────────────────── - let session; - try { - session = await requireSession(); - } catch (e) { - if (e instanceof UnauthorizedError) { - return NextResponse.json( - { error: "인증이 필요합니다." }, - { status: 401 }, - ); - } - throw e; - } - - // ── 2. 입력 검증 ────────────────────────────────────────── + // ── 1. 입력 검증 ────────────────────────────────────────── const body = await req.json().catch(() => null); const parsed = ConnectSchema.safeParse(body); if (!parsed.success) { @@ -47,82 +31,28 @@ export async function POST(req: NextRequest) { } const { appleId, appPassword } = parsed.data; - // ── 3. CalDAV Discovery ─────────────────────────────────── + // ── 2. CalDAV Discovery (자격증명 검증 + 엔드포인트 탐색) ── try { const discovery = await discoverCalDAV({ username: appleId, password: appPassword, }); - // ── 4. 앱 전용 암호 암호화 ─────────────────────────────── - const encryptedData = encrypt(appPassword); - const encryptedPassword = serializeEncrypted(encryptedData); - const encryptionIv = encryptedData.iv; - - // ── 5. DB upsert ───────────────────────────────────────── - const supabase = await createClient(); - - const { data: connection, error: connError } = await supabase - .from("icloud_connections") - .upsert( - { - profile_id: session.userId, - apple_id: appleId, - encrypted_password: encryptedPassword, - encryption_iv: encryptionIv, - principal_url: discovery.principalUrl, - calendar_home_url: discovery.calendarHomeUrl, - is_active: true, - last_verified_at: new Date().toISOString(), - }, - { onConflict: "profile_id,apple_id" }, - ) - .select("id") - .single(); - - if (connError || !connection) { - console.error("[icloud.connect] DB upsert 실패", { - userId: session.userId, - appleId: maskEmail(appleId), - error: connError?.message, - }); - return NextResponse.json( - { error: "연결 정보 저장 중 오류가 발생했습니다." }, - { status: 500 }, - ); - } - - // ── 6. 캘린더 목록 upsert ───────────────────────────────── - if (discovery.calendars.length > 0) { - const calendarRows = discovery.calendars.map((c: CalendarInfo) => ({ - connection_id: connection.id, - display_name: c.displayName, - calendar_url: c.url, - color: c.color ?? null, - ctag: c.ctag ?? null, - synced_at: new Date().toISOString(), - })); - - const { error: calError } = await supabase - .from("icloud_calendars") - .upsert(calendarRows, { onConflict: "connection_id,calendar_url" }); - - if (calError) { - console.warn("[icloud.connect] 캘린더 목록 저장 실패 (연결은 성공)", { - userId: session.userId, - error: calError.message, - }); - } - } + // ── 3. 연결 정보를 암호화해 HttpOnly 쿠키에 저장 ────────── + await saveConnection({ + appleId, + appPassword, + principalUrl: discovery.principalUrl, + calendarHomeUrl: discovery.calendarHomeUrl, + }); console.info("[icloud.connect] 연결 성공", { - userId: session.userId, appleId: maskEmail(appleId), calendarsCount: discovery.calendars.length, }); return NextResponse.json({ - connectionId: connection.id, + appleId, principalUrl: discovery.principalUrl, calendarHomeUrl: discovery.calendarHomeUrl, calendarsCount: discovery.calendars.length, @@ -139,7 +69,6 @@ export async function POST(req: NextRequest) { ); } console.error("[icloud.connect] CalDAV 오류", { - userId: session.userId, appleId: maskEmail(appleId), statusCode: err.statusCode, message: err.message, @@ -153,7 +82,6 @@ export async function POST(req: NextRequest) { } console.error("[icloud.connect] 예상치 못한 오류", { - userId: session.userId, appleId: maskEmail(appleId), error: err instanceof Error ? err.message : "unknown", }); diff --git a/src/app/api/icloud/disconnect/route.ts b/src/app/api/icloud/disconnect/route.ts index db6e2ed..c8a156e 100644 --- a/src/app/api/icloud/disconnect/route.ts +++ b/src/app/api/icloud/disconnect/route.ts @@ -1,41 +1,12 @@ import { NextResponse } from "next/server"; -import { requireSession, UnauthorizedError } from "@/lib/auth/session"; -import { createClient } from "@/lib/supabase/server"; +import { clearConnection } from "@/lib/caldav/connection-cookie"; export const dynamic = "force-dynamic"; export async function POST() { - let session; try { - session = await requireSession(); - } catch (e) { - if (e instanceof UnauthorizedError) { - return NextResponse.json( - { error: "인증이 필요합니다." }, - { status: 401 }, - ); - } - throw e; - } - - try { - const supabase = await createClient(); - - // 사용자의 icloud_connections 데이터 영구 삭제 (Hard Delete) - // 외래키 cascade 설정에 의해 관련 icloud_calendars 레코드도 함께 지워집니다. - const { error } = await supabase - .from("icloud_connections") - .delete() - .eq("profile_id", session.userId); - - if (error) { - console.error("[icloud.disconnect] DB 삭제 오류:", error.message); - return NextResponse.json( - { error: "연동 해제 중 오류가 발생했습니다." }, - { status: 500 }, - ); - } - + // 연결 정보 쿠키를 삭제한다. 앱 전용 암호도 함께 파기된다. + await clearConnection(); return NextResponse.json({ success: true }); } catch (err) { console.error("[icloud.disconnect] 예상치 못한 오류:", err); diff --git a/src/app/api/icloud/events/create/route.ts b/src/app/api/icloud/events/create/route.ts index 0bb033a..883c2f1 100644 --- a/src/app/api/icloud/events/create/route.ts +++ b/src/app/api/icloud/events/create/route.ts @@ -1,17 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { requireSession, UnauthorizedError } from "@/lib/auth/session"; -import { decrypt, deserializeEncrypted, maskEmail } from "@/lib/crypto"; +import { maskEmail } from "@/lib/crypto"; import { createEvent } from "@/lib/caldav/create"; import { buildIcs } from "@/lib/ics/builder"; import { CalDAVError } from "@/lib/caldav/client"; -import { createClient } from "@/lib/supabase/server"; -import type { ICloudConnectionRow, ICloudCalendarRow } from "@/types/icloud"; +import { getConnectionAuth } from "@/lib/caldav/connection-cookie"; export const dynamic = "force-dynamic"; const CreateEventSchema = z.object({ - calendarId: z.string().uuid("calendarId는 UUID 형식이어야 합니다."), + calendarUrl: z + .string() + .url("calendarUrl은 올바른 URL 형식이어야 합니다.") + .startsWith("https://", "calendarUrl은 https URL이어야 합니다."), title: z .string() .min(1, "제목을 입력해주세요.") @@ -27,18 +28,13 @@ const CreateEventSchema = z.object({ }); export async function POST(req: NextRequest) { - // ── 1. 인증 검증 ────────────────────────────────────────── - let session; - try { - session = await requireSession(); - } catch (e) { - if (e instanceof UnauthorizedError) { - return NextResponse.json( - { error: "인증이 필요합니다." }, - { status: 401 }, - ); - } - throw e; + // ── 1. 연결 정보 확인 ───────────────────────────────────── + const connection = await getConnectionAuth(); + if (!connection) { + return NextResponse.json( + { error: "연결된 iCloud 계정이 없습니다. 먼저 계정을 연결해주세요." }, + { status: 404 }, + ); } // ── 2. 입력 검증 ────────────────────────────────────────── @@ -50,7 +46,7 @@ export async function POST(req: NextRequest) { { status: 400 }, ); } - const { calendarId, title, startAt, endAt, location, description } = + const { calendarUrl, title, startAt, endAt, location, description } = parsed.data; const start = new Date(startAt); @@ -62,36 +58,7 @@ export async function POST(req: NextRequest) { ); } - // ── 3. 캘린더 + 연결 정보 조회 (소유권 확인) ────────────── - const supabase = await createClient(); - - const { data: calendar, error: calError } = await supabase - .from("icloud_calendars") - .select("*, icloud_connections(*)") - .eq("id", calendarId) - .single(); - - if (calError || !calendar) { - return NextResponse.json( - { error: "캘린더를 찾을 수 없습니다." }, - { status: 404 }, - ); - } - - const connection = ( - calendar as ICloudCalendarRow & { - icloud_connections: ICloudConnectionRow; - } - ).icloud_connections; - - if (!connection || connection.profile_id !== session.userId) { - return NextResponse.json( - { error: "접근 권한이 없습니다." }, - { status: 403 }, - ); - } - - // ── 4. ICS 빌드 ─────────────────────────────────────────── + // ── 3. ICS 빌드 ─────────────────────────────────────────── const { uid, icsContent } = buildIcs({ title, startAt: start, @@ -100,25 +67,17 @@ export async function POST(req: NextRequest) { description, }); - // ── 5. CalDAV PUT ───────────────────────────────────────── + // ── 4. CalDAV PUT ───────────────────────────────────────── try { - const plainPassword = decrypt( - deserializeEncrypted( - connection.encrypted_password, - connection.encryption_iv, - ), - ); - const result = await createEvent( - (calendar as ICloudCalendarRow).calendar_url, - { username: connection.apple_id, password: plainPassword }, + calendarUrl, + { username: connection.appleId, password: connection.password }, uid, icsContent, ); console.info("[icloud.events.create] 일정 생성 성공", { - userId: session.userId, - calendarId, + appleId: maskEmail(connection.appleId), uid, }); @@ -144,9 +103,7 @@ export async function POST(req: NextRequest) { } console.error("[icloud.events.create] 오류", { - userId: session.userId, - calendarId, - appleId: maskEmail(connection.apple_id), + appleId: maskEmail(connection.appleId), error: err instanceof Error ? err.message : "unknown", }); return NextResponse.json( diff --git a/src/app/api/icloud/events/query/route.ts b/src/app/api/icloud/events/query/route.ts index c39f7a7..be477da 100644 --- a/src/app/api/icloud/events/query/route.ts +++ b/src/app/api/icloud/events/query/route.ts @@ -1,17 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { requireSession, UnauthorizedError } from "@/lib/auth/session"; -import { decrypt, deserializeEncrypted } from "@/lib/crypto"; +import { maskEmail } from "@/lib/crypto"; import { queryEvents } from "@/lib/caldav/query"; import { parseIcsToEvents } from "@/lib/ics/parser"; import { CalDAVError } from "@/lib/caldav/client"; -import { createClient } from "@/lib/supabase/server"; -import type { ICloudConnectionRow, ICloudCalendarRow } from "@/types/icloud"; +import { getConnectionAuth } from "@/lib/caldav/connection-cookie"; export const dynamic = "force-dynamic"; const QuerySchema = z.object({ - calendarId: z.string().uuid("calendarId는 UUID 형식이어야 합니다."), + calendarUrl: z + .string() + .url("calendarUrl은 올바른 URL 형식이어야 합니다.") + .startsWith("https://", "calendarUrl은 https URL이어야 합니다."), startDate: z .string() .datetime({ message: "startDate는 ISO 8601 형식이어야 합니다." }), @@ -21,18 +22,13 @@ const QuerySchema = z.object({ }); export async function POST(req: NextRequest) { - // ── 1. 인증 검증 ────────────────────────────────────────── - let session; - try { - session = await requireSession(); - } catch (e) { - if (e instanceof UnauthorizedError) { - return NextResponse.json( - { error: "인증이 필요합니다." }, - { status: 401 }, - ); - } - throw e; + // ── 1. 연결 정보 확인 ───────────────────────────────────── + const connection = await getConnectionAuth(); + if (!connection) { + return NextResponse.json( + { error: "연결된 iCloud 계정이 없습니다. 먼저 계정을 연결해주세요." }, + { status: 404 }, + ); } // ── 2. 입력 검증 ────────────────────────────────────────── @@ -44,7 +40,7 @@ export async function POST(req: NextRequest) { { status: 400 }, ); } - const { calendarId, startDate, endDate } = parsed.data; + const { calendarUrl, startDate, endDate } = parsed.data; const start = new Date(startDate); const end = new Date(endDate); @@ -55,53 +51,16 @@ export async function POST(req: NextRequest) { ); } - // ── 3. 캘린더 + 연결 정보 조회 (소유권 확인) ────────────── - const supabase = await createClient(); - - const { data: calendar, error: calError } = await supabase - .from("icloud_calendars") - .select("*, icloud_connections(*)") - .eq("id", calendarId) - .single(); - - if (calError || !calendar) { - return NextResponse.json( - { error: "캘린더를 찾을 수 없습니다." }, - { status: 404 }, - ); - } - - // RLS로 보호되지만 소유권을 한 번 더 확인 - const connection = ( - calendar as ICloudCalendarRow & { - icloud_connections: ICloudConnectionRow; - } - ).icloud_connections; - - if (!connection || connection.profile_id !== session.userId) { - return NextResponse.json( - { error: "접근 권한이 없습니다." }, - { status: 403 }, - ); - } - - // ── 4. CalDAV REPORT ────────────────────────────────────── + // ── 3. CalDAV REPORT ────────────────────────────────────── try { - const plainPassword = decrypt( - deserializeEncrypted( - connection.encrypted_password, - connection.encryption_iv, - ), - ); - const rawEvents = await queryEvents( - (calendar as ICloudCalendarRow).calendar_url, - { username: connection.apple_id, password: plainPassword }, + calendarUrl, + { username: connection.appleId, password: connection.password }, start, end, ); - // ── 5. ICS 파싱 ─────────────────────────────────────── + // ── 4. ICS 파싱 ─────────────────────────────────────── const events = rawEvents.flatMap((raw) => parseIcsToEvents(raw.icsData).map((e) => ({ uid: e.uid, @@ -125,8 +84,7 @@ export async function POST(req: NextRequest) { } console.error("[icloud.events.query] 오류", { - userId: session.userId, - calendarId, + appleId: maskEmail(connection.appleId), error: err instanceof Error ? err.message : "unknown", }); return NextResponse.json( diff --git a/src/lib/caldav/connection-cookie.ts b/src/lib/caldav/connection-cookie.ts new file mode 100644 index 0000000..f56e2f7 --- /dev/null +++ b/src/lib/caldav/connection-cookie.ts @@ -0,0 +1,116 @@ +import { cookies } from "next/headers"; +import { + encrypt, + decrypt, + serializeEncrypted, + deserializeEncrypted, +} from "@/lib/crypto"; + +// ============================================================ +// iCloud 연결 정보 쿠키 저장소 +// +// Google/Naver 캘린더(`google/auth.ts`)와 동일하게, 연동 정보를 HttpOnly +// 쿠키에 저장한다. 앱의 실제 인증은 커스텀 JWT 쿠키(`accessToken`)를 쓰므로 +// Supabase Auth 세션/RLS에 의존하지 않는다. +// +// 앱 전용 암호는 장기 유효한 민감 정보라 AES-256-GCM(`crypto.ts`)으로 암호화해 +// 보관한다. 따라서 ENCRYPTION_SECRET 환경변수가 필요하다. +// ============================================================ + +const CONNECTION_COOKIE_NAME = "icloud_connection"; +const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30일 + +/** API 응답/상태 표시에 쓰는 공개 연결 정보 (앱 암호 미포함) */ +export interface ICloudConnection { + appleId: string; + principalUrl: string; + calendarHomeUrl: string; +} + +/** CalDAV 요청에 쓰는 인증 정보 (복호화된 앱 암호 포함) — 서버 전용 */ +export interface ICloudConnectionAuth extends ICloudConnection { + password: string; +} + +/** 쿠키에 직렬화되어 저장되는 형태 (암호화된 앱 암호 포함) */ +interface StoredConnection extends ICloudConnection { + /** "base64(ciphertext):base64(authTag)" */ + encryptedPassword: string; + /** base64(12-byte IV) */ + encryptionIv: string; +} + +/** + * 연결 정보를 HttpOnly 쿠키에 저장한다. + * 앱 전용 암호는 AES-GCM으로 암호화해 보관한다. + */ +export async function saveConnection(params: { + appleId: string; + appPassword: string; + principalUrl: string; + calendarHomeUrl: string; +}): Promise { + const enc = encrypt(params.appPassword); + const stored: StoredConnection = { + appleId: params.appleId, + principalUrl: params.principalUrl, + calendarHomeUrl: params.calendarHomeUrl, + encryptedPassword: serializeEncrypted(enc), + encryptionIv: enc.iv, + }; + + const cookieStore = await cookies(); + cookieStore.set(CONNECTION_COOKIE_NAME, JSON.stringify(stored), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: COOKIE_MAX_AGE, + }); +} + +/** 쿠키에서 공개 연결 정보를 읽는다. 앱 암호는 포함하지 않는다. */ +export async function getConnection(): Promise { + const stored = await readStored(); + if (!stored) return null; + return { + appleId: stored.appleId, + principalUrl: stored.principalUrl, + calendarHomeUrl: stored.calendarHomeUrl, + }; +} + +/** + * CalDAV 요청용 인증 정보(복호화된 앱 암호 포함)를 읽는다. + * 서버 라우트 핸들러에서만 사용해야 한다. + */ +export async function getConnectionAuth(): Promise { + const stored = await readStored(); + if (!stored) return null; + const password = decrypt( + deserializeEncrypted(stored.encryptedPassword, stored.encryptionIv), + ); + return { + appleId: stored.appleId, + principalUrl: stored.principalUrl, + calendarHomeUrl: stored.calendarHomeUrl, + password, + }; +} + +/** 연결 쿠키를 삭제한다. */ +export async function clearConnection(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(CONNECTION_COOKIE_NAME); +} + +async function readStored(): Promise { + const cookieStore = await cookies(); + const raw = cookieStore.get(CONNECTION_COOKIE_NAME)?.value; + if (!raw) return null; + try { + return JSON.parse(raw) as StoredConnection; + } catch { + return null; + } +} diff --git a/src/types/icloud.ts b/src/types/icloud.ts index ff4d2bb..499d0af 100644 --- a/src/types/icloud.ts +++ b/src/types/icloud.ts @@ -62,31 +62,3 @@ export interface CreateEventResult { href: string; etag: string; } - -/** DB의 icloud_connections 행 */ -export interface ICloudConnectionRow { - id: string; - profile_id: string; - apple_id: string; - encrypted_password: string; - encryption_iv: string; - principal_url: string | null; - calendar_home_url: string | null; - is_active: boolean; - last_verified_at: string | null; - created_at: string; - updated_at: string; -} - -/** DB의 icloud_calendars 행 */ -export interface ICloudCalendarRow { - id: string; - connection_id: string; - display_name: string; - calendar_url: string; - color: string | null; - ctag: string | null; - synced_at: string | null; - created_at: string; - updated_at: string; -}