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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 19 additions & 34 deletions src/app/api/calendar/status/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
117 changes: 18 additions & 99 deletions src/app/api/icloud/calendars/route.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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(
Expand Down
96 changes: 12 additions & 84 deletions src/app/api/icloud/connect/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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",
});
Expand Down
Loading
Loading