One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(at-oauth): use PAR request_uri with callback-bound txn

+1884 -342
+16 -1
.env.example
··· 1 - # Required 1 + # Required 2 2 NEXT_PUBLIC_BASE_URL=http://localhost:3000 3 + NEXT_PUBLIC_APP_URL=http://localhost:3000 3 4 SALT=Backup-Salt 4 5 5 6 # Auth (Required) 6 7 NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your-clerk-publishable-key 7 8 CLERK_SECRET_KEY=your-clerk-secret 9 + 10 + # ATProto / Atmosphere (Required if enabling ATProto login) 11 + # Preferred: key rotation format (first key is active): kid:secret,kid_old:secret_old 12 + ATPROTO_SESSION_KEYS=v1:replace-with-strong-random-secret 13 + # Alternative rotation envs (used when ATPROTO_SESSION_KEYS is empty) 14 + ATPROTO_SESSION_SECRET_CURRENT= 15 + ATPROTO_SESSION_SECRET_PREVIOUS= 16 + # Legacy fallback secret (used only when rotation envs above are empty) 17 + ATPROTO_SESSION_SECRET= 18 + # Optional explicit OAuth client_id. If empty, app uses: ${NEXT_PUBLIC_APP_URL}/oauth-client-metadata.json 19 + ATPROTO_CLIENT_ID= 20 + 21 + # Optional fallback secret for legacy compatibility 22 + NEXTAUTH_SECRET= 8 23 9 24 # Optional, database 10 25 POSTGRES_URL=postgres://postgres:postgres@localhost:5432/onecalendar
+24
README.md
··· 123 123 124 124 Copy `.env.example` to `.env` and fill in. 125 125 126 + Key variables: 127 + 128 + ```env 129 + # Core 130 + NEXT_PUBLIC_BASE_URL=http://localhost:3000 131 + NEXT_PUBLIC_APP_URL=http://localhost:3000 132 + SALT=Backup-Salt 133 + 134 + # Clerk 135 + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=... 136 + CLERK_SECRET_KEY=... 137 + 138 + # ATProto / Atmosphere (required for /at-oauth) 139 + ATPROTO_SESSION_KEYS=v1:...,v0:... # key rotation (first key is active) 140 + ATPROTO_SESSION_SECRET_CURRENT=... # optional alt rotation vars 141 + ATPROTO_SESSION_SECRET_PREVIOUS=... 142 + ATPROTO_SESSION_SECRET=... # legacy fallback 143 + ATPROTO_CLIENT_ID= # optional override; defaults to ${NEXT_PUBLIC_APP_URL}/oauth-client-metadata.json 144 + NEXTAUTH_SECRET= # optional legacy fallback 145 + 146 + # Optional DB (backup/share sync) 147 + POSTGRES_URL=postgres://postgres:postgres@localhost:5432/onecalendar 148 + ``` 149 + 126 150 ## Tech Stack 127 151 128 152 - [Next.js](https://nextjs.org)
+9 -1
app/(app)/app/page.tsx
··· 14 14 } 15 15 16 16 export default function Home() { 17 - const { isLoaded } = useUser() 17 + const { isLoaded, isSignedIn } = useUser() 18 18 const [hasSessionCookie, setHasSessionCookie] = useState(hasClerkSessionCookie) 19 19 const [minimumWaitDone, setMinimumWaitDone] = useState(false) 20 + const [atprotoLogoutDone, setAtprotoLogoutDone] = useState(false) 20 21 21 22 useEffect(() => { 22 23 const waitTimer = window.setTimeout(() => { ··· 34 35 window.clearInterval(cookieCheckTimer) 35 36 } 36 37 }, []) 38 + 39 + useEffect(() => { 40 + if (!isLoaded || !isSignedIn || atprotoLogoutDone) return 41 + fetch("/api/atproto/logout", { method: "POST" }) 42 + .catch(() => undefined) 43 + .finally(() => setAtprotoLogoutDone(true)) 44 + }, [isLoaded, isSignedIn, atprotoLogoutDone]) 37 45 38 46 const shouldShowAuthWait = useMemo(() => { 39 47 if (!minimumWaitDone) return true
+1
app/(auth)/sign-in/sso-callback/page.tsx
··· 7 7 const { setSession } = useClerk(); 8 8 9 9 useEffect(() => { 10 + fetch("/api/atproto/logout", { method: "POST" }).catch(() => undefined); 10 11 setSession?.({ forceRedirectUrl: '/app' }); 11 12 }, [setSession]); 12 13
+1
app/(auth)/sign-up/sso-callback/page.tsx
··· 7 7 const { setSession } = useClerk(); 8 8 9 9 useEffect(() => { 10 + fetch("/api/atproto/logout", { method: "POST" }).catch(() => undefined); 10 11 setSession?.({ forceRedirectUrl: '/app' }); 11 12 }, [setSession]); 12 13
+9
app/[handle]/[shareId]/page.tsx
··· 1 + "use client"; 2 + 3 + import SharedEventView from "@/components/app/profile/shared-event"; 4 + import { useParams } from "next/navigation"; 5 + 6 + export default function AtprotoSharePage() { 7 + const params = useParams(); 8 + return <SharedEventView handle={params.handle as string} shareId={params.shareId as string} />; 9 + }
+160
app/api/atproto/callback/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { getActorProfileRecord, getProfile, profileAvatarBlobUrl } from "@/lib/atproto"; 3 + import { setAtprotoSession } from "@/lib/atproto-auth"; 4 + import { clearAtprotoOAuthTxnCookie, consumeAtprotoOAuthTxn, getAtprotoOAuthTxnFromRequest } from "@/lib/atproto-oauth-txn"; 5 + import { createDpopProof, type DpopPublicJwk } from "@/lib/dpop"; 6 + 7 + function parseJsonSafe<T>(value: string): T | null { 8 + try { 9 + return JSON.parse(value) as T; 10 + } catch { 11 + return null; 12 + } 13 + } 14 + 15 + function redirectWithError(baseUrl: string, error: string, reason?: string) { 16 + const url = new URL(`${baseUrl}/at-oauth`); 17 + url.searchParams.set("error", error); 18 + if (reason) { 19 + url.searchParams.set("reason", reason); 20 + } 21 + 22 + const response = NextResponse.redirect(url.toString()); 23 + clearAtprotoOAuthTxnCookie(response); 24 + return response; 25 + } 26 + 27 + function normalizeIssuerOrigin(value: string) { 28 + const parsed = new URL(value); 29 + if (parsed.protocol !== "https:") { 30 + throw new Error("Issuer must use https"); 31 + } 32 + return parsed.origin; 33 + } 34 + 35 + export async function GET(request: NextRequest) { 36 + const code = request.nextUrl.searchParams.get("code"); 37 + const state = request.nextUrl.searchParams.get("state"); 38 + const iss = request.nextUrl.searchParams.get("iss"); 39 + 40 + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin; 41 + const txn = getAtprotoOAuthTxnFromRequest(request); 42 + 43 + if (!code || !state || !txn || state !== txn.state) { 44 + return redirectWithError(baseUrl, "oauth_state_mismatch"); 45 + } 46 + 47 + if (!consumeAtprotoOAuthTxn(txn)) { 48 + return redirectWithError(baseUrl, "oauth_state_mismatch", "transaction_already_used"); 49 + } 50 + 51 + const { verifier, handle, pds, did, dpopPrivateKeyPem, dpopPublicJwk } = txn; 52 + 53 + if (!dpopPublicJwk?.kty || !dpopPublicJwk?.crv || !dpopPublicJwk?.x || !dpopPublicJwk?.y) { 54 + return redirectWithError(baseUrl, "invalid_dpop_key"); 55 + } 56 + 57 + let issuerOrigin: string; 58 + let pdsOrigin: string; 59 + try { 60 + pdsOrigin = normalizeIssuerOrigin(pds); 61 + issuerOrigin = iss ? normalizeIssuerOrigin(iss) : pdsOrigin; 62 + } catch { 63 + return redirectWithError(baseUrl, "invalid_issuer"); 64 + } 65 + 66 + if (issuerOrigin !== pdsOrigin) { 67 + return redirectWithError(baseUrl, "invalid_issuer", "issuer_mismatch"); 68 + } 69 + 70 + const clientId = process.env.ATPROTO_CLIENT_ID || `${baseUrl}/oauth-client-metadata.json`; 71 + const redirectUri = `${baseUrl}/api/atproto/callback`; 72 + const tokenUrl = `${issuerOrigin}/oauth/token`; 73 + 74 + const makeTokenRequest = async (nonce?: string) => { 75 + const dpopProof = createDpopProof({ 76 + htu: tokenUrl, 77 + htm: "POST", 78 + privateKeyPem: dpopPrivateKeyPem, 79 + publicJwk: dpopPublicJwk as DpopPublicJwk, 80 + nonce, 81 + }); 82 + 83 + return fetch(tokenUrl, { 84 + method: "POST", 85 + headers: { 86 + "Content-Type": "application/x-www-form-urlencoded", 87 + DPoP: dpopProof, 88 + }, 89 + body: new URLSearchParams({ 90 + grant_type: "authorization_code", 91 + code, 92 + redirect_uri: redirectUri, 93 + client_id: clientId, 94 + code_verifier: verifier, 95 + }), 96 + }); 97 + }; 98 + 99 + let tokenRes = await makeTokenRequest(); 100 + if (!tokenRes.ok) { 101 + const nonce = tokenRes.headers.get("DPoP-Nonce") || tokenRes.headers.get("dpop-nonce"); 102 + if (nonce) { 103 + tokenRes = await makeTokenRequest(nonce); 104 + } 105 + } 106 + 107 + if (!tokenRes.ok) { 108 + const detailText = await tokenRes.text(); 109 + const detailJson = parseJsonSafe<{ error?: string; error_description?: string }>(detailText); 110 + const reason = detailJson?.error_description || detailJson?.error || detailText.slice(0, 160) || "token_exchange_failed"; 111 + return redirectWithError(baseUrl, "token_exchange_failed", reason); 112 + } 113 + 114 + const tokenData = (await tokenRes.json()) as { access_token?: string; refresh_token?: string; sub?: string }; 115 + if (!tokenData.access_token) { 116 + return redirectWithError(baseUrl, "missing_access_token"); 117 + } 118 + 119 + const actorDid = tokenData.sub || did; 120 + if (!actorDid) { 121 + return redirectWithError(baseUrl, "missing_subject"); 122 + } 123 + 124 + const profile = await getProfile(pds, actorDid, tokenData.access_token, { 125 + privateKeyPem: dpopPrivateKeyPem, 126 + publicJwk: dpopPublicJwk, 127 + }); 128 + 129 + const actorProfile = await getActorProfileRecord({ 130 + pds, 131 + repo: actorDid, 132 + accessToken: tokenData.access_token, 133 + dpopPrivateKeyPem, 134 + dpopPublicJwk, 135 + }).catch(() => undefined); 136 + 137 + const avatarCid = actorProfile?.avatar?.ref?.$link; 138 + const avatarUrl = profileAvatarBlobUrl({ pds, did: actorDid, cid: avatarCid }) || profile?.avatar; 139 + 140 + await setAtprotoSession({ 141 + did: actorDid, 142 + handle: profile?.handle || handle, 143 + pds, 144 + accessToken: tokenData.access_token, 145 + refreshToken: tokenData.refresh_token, 146 + displayName: actorProfile?.displayName || profile?.displayName, 147 + avatar: avatarUrl, 148 + dpopPrivateKeyPem, 149 + dpopPublicJwk, 150 + }); 151 + 152 + const response = NextResponse.redirect(`${baseUrl}/app`); 153 + clearAtprotoOAuthTxnCookie(response); 154 + 155 + ["__session", "__client_uat", "__clerk_db_jwt", "__clerk_handshake"].forEach((key) => { 156 + response.cookies.delete(key); 157 + }); 158 + 159 + return response; 160 + }
+118
app/api/atproto/login/route.ts
··· 1 + import { randomUUID } from "node:crypto"; 2 + import { NextRequest, NextResponse } from "next/server"; 3 + import { createPkcePair, resolveHandle } from "@/lib/atproto"; 4 + import { generateDpopKeyMaterial } from "@/lib/dpop"; 5 + import { setAtprotoOAuthTxnCookie } from "@/lib/atproto-oauth-txn"; 6 + 7 + const LOGIN_RATE_WINDOW_MS = 10 * 60 * 1000; 8 + const LOGIN_RATE_LIMIT = 20; 9 + const loginRateCache = new Map<string, { count: number; resetAt: number }>(); 10 + 11 + function getExpectedBaseUrl(request: NextRequest) { 12 + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin; 13 + } 14 + 15 + function isAllowedOrigin(request: NextRequest, expectedBaseUrl: string) { 16 + const expected = new URL(expectedBaseUrl); 17 + const origin = request.headers.get("origin"); 18 + if (origin) { 19 + try { 20 + const parsed = new URL(origin); 21 + if (parsed.origin !== expected.origin) return false; 22 + } catch { 23 + return false; 24 + } 25 + } 26 + 27 + const host = (request.headers.get("x-forwarded-host") || request.headers.get("host") || "").toLowerCase(); 28 + if (host && host !== expected.host.toLowerCase()) { 29 + return false; 30 + } 31 + 32 + return true; 33 + } 34 + 35 + function checkRateLimit(request: NextRequest, handle: string) { 36 + const now = Date.now(); 37 + for (const [key, value] of loginRateCache.entries()) { 38 + if (value.resetAt <= now) loginRateCache.delete(key); 39 + } 40 + 41 + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; 42 + const key = `${ip}:${handle}`; 43 + const record = loginRateCache.get(key); 44 + 45 + if (!record || record.resetAt <= now) { 46 + loginRateCache.set(key, { count: 1, resetAt: now + LOGIN_RATE_WINDOW_MS }); 47 + return true; 48 + } 49 + 50 + if (record.count >= LOGIN_RATE_LIMIT) { 51 + return false; 52 + } 53 + 54 + record.count += 1; 55 + loginRateCache.set(key, record); 56 + return true; 57 + } 58 + 59 + export async function POST(request: NextRequest) { 60 + const expectedBaseUrl = getExpectedBaseUrl(request); 61 + if (!isAllowedOrigin(request, expectedBaseUrl)) { 62 + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 63 + } 64 + 65 + const { handle } = (await request.json()) as { handle?: string }; 66 + if (!handle) return NextResponse.json({ error: "Missing handle" }, { status: 400 }); 67 + 68 + const normalizedHandle = handle.replace(/^@/, "").toLowerCase(); 69 + if (!/^[a-z0-9.-]{3,253}$/.test(normalizedHandle)) { 70 + return NextResponse.json({ error: "Invalid handle" }, { status: 400 }); 71 + } 72 + 73 + if (!checkRateLimit(request, normalizedHandle)) { 74 + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); 75 + } 76 + 77 + const { did, pds } = await resolveHandle(normalizedHandle); 78 + const { verifier, challenge } = createPkcePair(); 79 + const state = randomUUID(); 80 + const dpop = generateDpopKeyMaterial(); 81 + 82 + const redirectUri = `${expectedBaseUrl}/api/atproto/callback`; 83 + const clientId = process.env.ATPROTO_CLIENT_ID || `${expectedBaseUrl}/oauth-client-metadata.json`; 84 + 85 + const authUrl = new URL(`${pds.replace(/\/$/, "")}/oauth/authorize`); 86 + authUrl.searchParams.set("client_id", clientId); 87 + authUrl.searchParams.set("redirect_uri", redirectUri); 88 + authUrl.searchParams.set("response_type", "code"); 89 + authUrl.searchParams.set("scope", "atproto transition:generic"); 90 + authUrl.searchParams.set("state", state); 91 + authUrl.searchParams.set("code_challenge", challenge); 92 + authUrl.searchParams.set("code_challenge_method", "S256"); 93 + authUrl.searchParams.set("dpop_jkt", dpop.jkt); 94 + 95 + const response = NextResponse.json({ authorizeUrl: authUrl.toString(), pds, did }); 96 + const secure = request.nextUrl.protocol === "https:" || process.env.NODE_ENV === "production"; 97 + setAtprotoOAuthTxnCookie( 98 + response, 99 + { 100 + jti: randomUUID(), 101 + state, 102 + verifier, 103 + handle: normalizedHandle, 104 + pds, 105 + did, 106 + dpopPrivateKeyPem: dpop.privateKeyPem, 107 + dpopPublicJwk: dpop.publicJwk, 108 + issuedAt: Math.floor(Date.now() / 1000), 109 + }, 110 + secure, 111 + ); 112 + 113 + ["__session", "__client_uat", "__clerk_db_jwt", "__clerk_handshake"].forEach((key) => { 114 + response.cookies.delete(key); 115 + }); 116 + 117 + return response; 118 + }
+7
app/api/atproto/logout/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import { clearAtprotoSession } from "@/lib/atproto-auth"; 3 + 4 + export async function POST() { 5 + await clearAtprotoSession(); 6 + return NextResponse.json({ success: true }); 7 + }
+73
app/api/atproto/register-url/route.ts
··· 1 + import { randomUUID } from "node:crypto"; 2 + import { NextRequest, NextResponse } from "next/server"; 3 + import { createPkcePair } from "@/lib/atproto"; 4 + import { setAtprotoOAuthTxnCookie } from "@/lib/atproto-oauth-txn"; 5 + import { generateDpopKeyMaterial } from "@/lib/dpop"; 6 + 7 + const ROSE_PDS_ORIGIN = "https://rose.madebydanny.uk"; 8 + 9 + function getBaseUrl(request: NextRequest) { 10 + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin; 11 + } 12 + 13 + export async function POST(request: NextRequest) { 14 + const baseUrl = getBaseUrl(request); 15 + const clientId = process.env.ATPROTO_CLIENT_ID || `${baseUrl}/oauth-client-metadata.json`; 16 + const authorizeUrl = new URL(`${ROSE_PDS_ORIGIN}/oauth/authorize`); 17 + 18 + const { verifier, challenge } = createPkcePair(); 19 + const state = randomUUID(); 20 + const dpop = generateDpopKeyMaterial(); 21 + const redirectUri = `${baseUrl}/api/atproto/callback`; 22 + 23 + const parRes = await fetch(`${ROSE_PDS_ORIGIN}/oauth/par`, { 24 + method: "POST", 25 + headers: { 26 + "Content-Type": "application/x-www-form-urlencoded", 27 + }, 28 + body: new URLSearchParams({ 29 + client_id: clientId, 30 + redirect_uri: redirectUri, 31 + response_type: "code", 32 + scope: "atproto transition:generic", 33 + state, 34 + code_challenge: challenge, 35 + code_challenge_method: "S256", 36 + dpop_jkt: dpop.jkt, 37 + }), 38 + cache: "no-store", 39 + }); 40 + 41 + if (!parRes.ok) { 42 + const detail = (await parRes.text()).slice(0, 200) || "par_failed"; 43 + return NextResponse.json({ error: `Rose PAR failed: ${detail}` }, { status: 502 }); 44 + } 45 + 46 + const parJson = (await parRes.json()) as { request_uri?: string }; 47 + if (!parJson.request_uri) { 48 + return NextResponse.json({ error: "Rose PAR response missing request_uri" }, { status: 502 }); 49 + } 50 + 51 + authorizeUrl.searchParams.set("client_id", clientId); 52 + authorizeUrl.searchParams.set("request_uri", parJson.request_uri); 53 + 54 + const response = NextResponse.json({ authorizeUrl: authorizeUrl.toString() }); 55 + const secure = request.nextUrl.protocol === "https:" || process.env.NODE_ENV === "production"; 56 + setAtprotoOAuthTxnCookie( 57 + response, 58 + { 59 + jti: randomUUID(), 60 + state, 61 + verifier, 62 + handle: "", 63 + pds: ROSE_PDS_ORIGIN, 64 + did: "", 65 + dpopPrivateKeyPem: dpop.privateKeyPem, 66 + dpopPublicJwk: dpop.publicJwk, 67 + issuedAt: Math.floor(Date.now() / 1000), 68 + }, 69 + secure, 70 + ); 71 + 72 + return response; 73 + }
+43
app/api/atproto/session/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import { getActorProfileRecord, profileAvatarBlobUrl } from "@/lib/atproto"; 3 + import { getAtprotoSession, setAtprotoSession } from "@/lib/atproto-auth"; 4 + 5 + export async function GET() { 6 + const session = await getAtprotoSession(); 7 + if (!session) return NextResponse.json({ signedIn: false }); 8 + 9 + let avatar = session.avatar; 10 + let displayName = session.displayName; 11 + 12 + if (session.accessToken && session.did && session.pds) { 13 + const actorProfile = await getActorProfileRecord({ 14 + pds: session.pds, 15 + repo: session.did, 16 + accessToken: session.accessToken, 17 + dpopPrivateKeyPem: session.dpopPrivateKeyPem, 18 + dpopPublicJwk: session.dpopPublicJwk, 19 + }).catch(() => undefined); 20 + 21 + const avatarCid = actorProfile?.avatar?.ref?.$link; 22 + const resolvedAvatar = profileAvatarBlobUrl({ pds: session.pds, did: session.did, cid: avatarCid }); 23 + 24 + if (resolvedAvatar || actorProfile?.displayName) { 25 + avatar = resolvedAvatar || avatar; 26 + displayName = actorProfile?.displayName || displayName; 27 + await setAtprotoSession({ 28 + ...session, 29 + avatar, 30 + displayName, 31 + }); 32 + } 33 + } 34 + 35 + return NextResponse.json({ 36 + signedIn: true, 37 + handle: session.handle, 38 + did: session.did, 39 + pds: session.pds, 40 + displayName, 41 + avatar, 42 + }); 43 + }
+105 -42
app/api/blob/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { currentUser } from "@clerk/nextjs/server"; 3 + import { Pool } from "pg"; 4 + import { getAtprotoSession } from "@/lib/atproto-auth"; 5 + import { deleteRecord, getRecord, putRecord } from "@/lib/atproto"; 1 6 2 - import { NextRequest, NextResponse } from "next/server" 3 - import { currentUser } from "@clerk/nextjs/server" 4 - import { Pool } from "pg" 5 - 6 - export const runtime = "nodejs" 7 + export const runtime = "nodejs"; 7 8 8 9 const pool = new Pool({ 9 10 connectionString: process.env.POSTGRES_URL, 10 11 ssl: { rejectUnauthorized: false }, 11 - }) 12 + }); 12 13 13 - let inited = false 14 + let inited = false; 14 15 15 16 async function initDB() { 16 - if (inited) return 17 - const client = await pool.connect() 17 + if (inited) return; 18 + const client = await pool.connect(); 18 19 try { 19 20 await client.query(` 20 21 CREATE TABLE IF NOT EXISTS calendar_backups ( ··· 23 24 iv TEXT NOT NULL, 24 25 timestamp TIMESTAMP NOT NULL 25 26 ) 26 - `) 27 - inited = true 27 + `); 28 + inited = true; 28 29 } finally { 29 - client.release() 30 + client.release(); 30 31 } 31 32 } 33 + 34 + const ATPROTO_BACKUP_COLLECTION = "app.onecalendar.backup"; 35 + const ATPROTO_BACKUP_RKEY = "latest"; 32 36 33 37 export async function POST(req: NextRequest) { 34 38 try { 35 - const user = await currentUser() 36 - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 37 - 38 - const body = await req.json() 39 - const encrypted_data = body?.ciphertext 40 - const iv = body?.iv 39 + const body = await req.json(); 40 + const encrypted_data = body?.ciphertext; 41 + const iv = body?.iv; 41 42 42 43 if (typeof encrypted_data !== "string" || typeof iv !== "string") { 43 - return NextResponse.json({ error: "Invalid payload" }, { status: 400 }) 44 + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); 45 + } 46 + 47 + const atproto = await getAtprotoSession(); 48 + if (atproto) { 49 + await putRecord({ 50 + pds: atproto.pds, 51 + repo: atproto.did, 52 + collection: ATPROTO_BACKUP_COLLECTION, 53 + rkey: ATPROTO_BACKUP_RKEY, 54 + accessToken: atproto.accessToken, 55 + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, 56 + dpopPublicJwk: atproto.dpopPublicJwk, 57 + record: { 58 + $type: ATPROTO_BACKUP_COLLECTION, 59 + ciphertext: encrypted_data, 60 + iv, 61 + updatedAt: new Date().toISOString(), 62 + }, 63 + }); 64 + return NextResponse.json({ success: true, backend: "atproto" }); 44 65 } 45 66 46 - await initDB() 67 + const user = await currentUser(); 68 + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 47 69 48 - const client = await pool.connect() 70 + await initDB(); 71 + 72 + const client = await pool.connect(); 49 73 try { 50 74 await client.query( 51 75 ` ··· 58 82 timestamp = EXCLUDED.timestamp 59 83 `, 60 84 [user.id, encrypted_data, iv, new Date().toISOString()], 61 - ) 62 - return NextResponse.json({ success: true }) 85 + ); 86 + return NextResponse.json({ success: true, backend: "postgres" }); 63 87 } finally { 64 - client.release() 88 + client.release(); 65 89 } 66 - } catch (e: any) { 67 - console.error(e) 68 - return NextResponse.json({ error: e?.message || "Internal error" }, { status: 500 }) 90 + } catch (e: unknown) { 91 + const message = e instanceof Error ? e.message : "Internal error"; 92 + return NextResponse.json({ error: message }, { status: 500 }); 69 93 } 70 94 } 71 95 72 96 export async function GET() { 73 - const user = await currentUser() 74 - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 97 + const atproto = await getAtprotoSession(); 98 + if (atproto) { 99 + try { 100 + const record = await getRecord({ 101 + pds: atproto.pds, 102 + repo: atproto.did, 103 + collection: ATPROTO_BACKUP_COLLECTION, 104 + rkey: ATPROTO_BACKUP_RKEY, 105 + accessToken: atproto.accessToken, 106 + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, 107 + dpopPublicJwk: atproto.dpopPublicJwk, 108 + }); 109 + const value = record.value ?? {}; 110 + return NextResponse.json({ 111 + ciphertext: value.ciphertext, 112 + iv: value.iv, 113 + timestamp: value.updatedAt, 114 + backend: "atproto", 115 + }); 116 + } catch { 117 + return NextResponse.json({ error: "Not found" }, { status: 404 }); 118 + } 119 + } 75 120 76 - await initDB() 121 + const user = await currentUser(); 122 + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 123 + 124 + await initDB(); 77 125 78 - const client = await pool.connect() 126 + const client = await pool.connect(); 79 127 try { 80 128 const result = await client.query( 81 129 `SELECT encrypted_data, iv, timestamp FROM calendar_backups WHERE user_id = $1`, 82 130 [user.id], 83 - ) 84 - if (result.rowCount === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }) 131 + ); 132 + if (result.rowCount === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }); 85 133 86 134 return NextResponse.json({ 87 135 ciphertext: result.rows[0].encrypted_data, 88 136 iv: result.rows[0].iv, 89 137 timestamp: result.rows[0].timestamp, 90 - }) 138 + backend: "postgres", 139 + }); 91 140 } finally { 92 - client.release() 141 + client.release(); 93 142 } 94 143 } 95 144 96 145 export async function DELETE() { 97 - const user = await currentUser() 98 - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 146 + const atproto = await getAtprotoSession(); 147 + if (atproto) { 148 + await deleteRecord({ 149 + pds: atproto.pds, 150 + repo: atproto.did, 151 + collection: ATPROTO_BACKUP_COLLECTION, 152 + rkey: ATPROTO_BACKUP_RKEY, 153 + accessToken: atproto.accessToken, 154 + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, 155 + dpopPublicJwk: atproto.dpopPublicJwk, 156 + }); 157 + return NextResponse.json({ success: true, backend: "atproto" }); 158 + } 99 159 100 - await initDB() 160 + const user = await currentUser(); 161 + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 101 162 102 - const client = await pool.connect() 163 + await initDB(); 164 + 165 + const client = await pool.connect(); 103 166 try { 104 - await client.query(`DELETE FROM calendar_backups WHERE user_id = $1`, [user.id]) 105 - return NextResponse.json({ success: true }) 167 + await client.query(`DELETE FROM calendar_backups WHERE user_id = $1`, [user.id]); 168 + return NextResponse.json({ success: true, backend: "postgres" }); 106 169 } finally { 107 - client.release() 170 + client.release(); 108 171 } 109 172 }
+148 -64
app/api/share/list/route.ts
··· 2 2 import { Pool } from "pg"; 3 3 import { currentUser } from "@clerk/nextjs/server"; 4 4 import crypto from "crypto"; 5 + import { getAtprotoSession } from "@/lib/atproto-auth"; 6 + import { deleteRecord, listRecords } from "@/lib/atproto"; 7 + import type { DpopPublicJwk } from "@/lib/dpop"; 5 8 6 - const pool = new Pool({ 7 - connectionString: process.env.POSTGRES_URL, 8 - ssl: { rejectUnauthorized: false }, 9 - }); 9 + const pool = new Pool({ connectionString: process.env.POSTGRES_URL, ssl: { rejectUnauthorized: false } }); 10 + const ALGORITHM = "aes-256-gcm"; 11 + const ATPROTO_SHARE_COLLECTION = "app.onecalendar.share"; 12 + 13 + let burnTableReady = false; 14 + 15 + async function ensureBurnTable() { 16 + if (burnTableReady) return; 17 + const client = await pool.connect(); 18 + try { 19 + await client.query(` 20 + CREATE TABLE IF NOT EXISTS atproto_share_burn_reads ( 21 + handle TEXT NOT NULL, 22 + owner_did TEXT, 23 + share_id TEXT NOT NULL, 24 + burned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 25 + pds_delete_synced BOOLEAN NOT NULL DEFAULT FALSE, 26 + PRIMARY KEY (share_id, handle) 27 + ) 28 + `); 29 + await client.query(`ALTER TABLE atproto_share_burn_reads ADD COLUMN IF NOT EXISTS owner_did TEXT`); 30 + await client.query(`ALTER TABLE atproto_share_burn_reads ADD COLUMN IF NOT EXISTS pds_delete_synced BOOLEAN NOT NULL DEFAULT FALSE`); 31 + await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_atproto_share_burn_reads_owner_share ON atproto_share_burn_reads(owner_did, share_id) WHERE owner_did IS NOT NULL`); 32 + burnTableReady = true; 33 + } finally { 34 + client.release(); 35 + } 36 + } 10 37 11 - const ALGORITHM = "aes-256-gcm"; 38 + async function syncBurnedAtprotoShares(ownerDid: string, handle: string, pds: string, accessToken: string, dpopPrivateKeyPem?: string, dpopPublicJwk?: DpopPublicJwk) { 39 + await ensureBurnTable(); 40 + 41 + const client = await pool.connect(); 42 + try { 43 + const pending = await client.query( 44 + "SELECT share_id FROM atproto_share_burn_reads WHERE (owner_did = $1 OR (owner_did IS NULL AND handle = $2)) AND pds_delete_synced = FALSE", 45 + [ownerDid, handle], 46 + ); 47 + 48 + if (!pending.rows.length) return; 49 + 50 + const syncedIds: string[] = []; 51 + for (const row of pending.rows) { 52 + const shareId = String(row.share_id); 53 + try { 54 + await deleteRecord({ 55 + pds, 56 + repo: ownerDid, 57 + collection: ATPROTO_SHARE_COLLECTION, 58 + rkey: shareId, 59 + accessToken, 60 + dpopPrivateKeyPem, 61 + dpopPublicJwk, 62 + }); 63 + syncedIds.push(shareId); 64 + } catch { 65 + // Keep pending rows for retry on next share management open. 66 + } 67 + } 68 + 69 + if (syncedIds.length > 0) { 70 + await client.query( 71 + "DELETE FROM atproto_share_burn_reads WHERE (owner_did = $1 OR (owner_did IS NULL AND handle = $2)) AND share_id = ANY($3::text[])", 72 + [ownerDid, handle, syncedIds], 73 + ); 74 + } 75 + } finally { 76 + client.release(); 77 + } 78 + } 12 79 13 80 function keyV2Unprotected(shareId: string) { 14 81 return crypto.createHash("sha256").update(shareId, "utf8").digest(); 15 82 } 16 83 17 84 function decryptWithKey(encryptedData: string, iv: string, authTag: string, key: Buffer) { 18 - const ivBuffer = Buffer.from(iv, "hex"); 19 - const authTagBuffer = Buffer.from(authTag, "hex"); 20 - const decipher = crypto.createDecipheriv(ALGORITHM, key, ivBuffer); 21 - decipher.setAuthTag(authTagBuffer); 85 + const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, "hex")); 86 + decipher.setAuthTag(Buffer.from(authTag, "hex")); 22 87 let decrypted = decipher.update(encryptedData, "hex", "utf8"); 23 88 decrypted += decipher.final("utf8"); 24 89 return decrypted; 25 90 } 26 91 27 92 export async function GET() { 28 - try { 29 - const user = await currentUser(); 30 - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 31 - const userId = user.id; 32 - 33 - const client = await pool.connect(); 34 - try { 35 - const result = await client.query( 36 - `SELECT share_id, encrypted_data, iv, auth_tag, timestamp, is_protected 37 - FROM shares 38 - WHERE user_id = $1 39 - ORDER BY timestamp DESC`, 40 - [userId] 41 - ); 93 + const atproto = await getAtprotoSession(); 94 + if (atproto) { 95 + await syncBurnedAtprotoShares( 96 + atproto.did, 97 + atproto.handle, 98 + atproto.pds, 99 + atproto.accessToken, 100 + atproto.dpopPrivateKeyPem, 101 + atproto.dpopPublicJwk, 102 + ); 42 103 43 - const shares = await Promise.all( 44 - result.rows.map(async (row) => { 45 - let eventId = ""; 46 - let eventTitle = ""; 104 + const data = await listRecords({ pds: atproto.pds, repo: atproto.did, collection: ATPROTO_SHARE_COLLECTION, accessToken: atproto.accessToken, dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, dpopPublicJwk: atproto.dpopPublicJwk }); 105 + const shares = (data.records || []).map((record) => { 106 + const rkey = record.uri.split("/").pop() || ""; 107 + const value = record.value ?? {}; 108 + let eventId = ""; 109 + let eventTitle = ""; 110 + if (!value.isProtected) { 111 + try { 112 + const decrypted = decryptWithKey(String(value.encryptedData), String(value.iv), String(value.authTag), keyV2Unprotected(rkey)); 113 + const parsed = JSON.parse(decrypted) as { id?: string; title?: string }; 114 + eventId = parsed.id || ""; 115 + eventTitle = parsed.title || ""; 116 + } catch { 117 + eventTitle = ""; 118 + } 119 + } 120 + return { 121 + id: rkey, 122 + eventId, 123 + eventTitle: value.isProtected ? "Protected" : eventTitle, 124 + sharedBy: atproto.handle, 125 + shareDate: String(value.timestamp || new Date().toISOString()), 126 + shareLink: `/${atproto.handle}/${rkey}`, 127 + isProtected: !!value.isProtected, 128 + }; 129 + }); 47 130 48 - if (!row.is_protected) { 49 - try { 50 - const key = keyV2Unprotected(row.share_id); 51 - const decrypted = decryptWithKey(row.encrypted_data, row.iv, row.auth_tag, key); 52 - const dataObj = JSON.parse(decrypted); 53 - eventId = dataObj.id ?? ""; 54 - eventTitle = dataObj.title ?? ""; 55 - } catch { 56 - eventId = ""; 57 - eventTitle = ""; 58 - } 59 - } else { 60 - eventId = "ๅ—ไฟๆŠค"; 61 - eventTitle = "ๅ—ไฟๆŠค"; 62 - } 131 + return NextResponse.json({ shares }); 132 + } 63 133 64 - return { 65 - id: row.share_id, 66 - eventId, 67 - eventTitle, 68 - sharedBy: userId, 69 - shareDate: row.timestamp.toISOString(), 70 - shareLink: `/share/${row.share_id}`, 71 - isProtected: row.is_protected, 72 - }; 73 - }) 74 - ); 134 + const user = await currentUser(); 135 + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 75 136 76 - return NextResponse.json({ shares }); 77 - } finally { 78 - client.release(); 79 - } 80 - } catch (error) { 81 - return NextResponse.json( 82 - { 83 - error: error instanceof Error ? error.message : "Unknown error", 84 - stack: error instanceof Error ? error.stack : undefined, 85 - }, 86 - { status: 500 } 137 + const client = await pool.connect(); 138 + try { 139 + const result = await client.query( 140 + `SELECT share_id, encrypted_data, iv, auth_tag, timestamp, is_protected FROM shares WHERE user_id = $1 ORDER BY timestamp DESC`, 141 + [user.id], 87 142 ); 143 + 144 + const shares = result.rows.map((row) => { 145 + let eventId = ""; 146 + let eventTitle = ""; 147 + if (!row.is_protected) { 148 + try { 149 + const decrypted = decryptWithKey(row.encrypted_data, row.iv, row.auth_tag, keyV2Unprotected(row.share_id)); 150 + const dataObj = JSON.parse(decrypted); 151 + eventId = dataObj.id ?? ""; 152 + eventTitle = dataObj.title ?? ""; 153 + } catch {} 154 + } else { 155 + eventId = "ๅ—ไฟๆŠค"; 156 + eventTitle = "ๅ—ไฟๆŠค"; 157 + } 158 + return { 159 + id: row.share_id, 160 + eventId, 161 + eventTitle, 162 + sharedBy: user.id, 163 + shareDate: row.timestamp.toISOString(), 164 + shareLink: `/share/${row.share_id}`, 165 + isProtected: row.is_protected, 166 + }; 167 + }); 168 + 169 + return NextResponse.json({ shares }); 170 + } finally { 171 + client.release(); 88 172 } 89 173 }
+115
app/api/share/public/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import crypto from "crypto"; 3 + import { Pool } from "pg"; 4 + import { getRecord, resolveHandle } from "@/lib/atproto"; 5 + 6 + const ALGORITHM = "aes-256-gcm"; 7 + const ATPROTO_SHARE_COLLECTION = "app.onecalendar.share"; 8 + 9 + const burnPool = process.env.POSTGRES_URL 10 + ? new Pool({ 11 + connectionString: process.env.POSTGRES_URL, 12 + ssl: { rejectUnauthorized: false }, 13 + }) 14 + : null; 15 + 16 + let burnTableReady = false; 17 + 18 + async function ensureBurnTable() { 19 + if (!burnPool || burnTableReady) return; 20 + const client = await burnPool.connect(); 21 + try { 22 + await client.query(` 23 + CREATE TABLE IF NOT EXISTS atproto_share_burn_reads ( 24 + handle TEXT NOT NULL, 25 + owner_did TEXT, 26 + share_id TEXT NOT NULL, 27 + burned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 28 + pds_delete_synced BOOLEAN NOT NULL DEFAULT FALSE, 29 + PRIMARY KEY (share_id, handle) 30 + ) 31 + `); 32 + await client.query(`ALTER TABLE atproto_share_burn_reads ADD COLUMN IF NOT EXISTS owner_did TEXT`); 33 + await client.query(`ALTER TABLE atproto_share_burn_reads ADD COLUMN IF NOT EXISTS pds_delete_synced BOOLEAN NOT NULL DEFAULT FALSE`); 34 + await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_atproto_share_burn_reads_owner_share ON atproto_share_burn_reads(owner_did, share_id) WHERE owner_did IS NOT NULL`); 35 + burnTableReady = true; 36 + } finally { 37 + client.release(); 38 + } 39 + } 40 + 41 + async function wasPublicBurnConsumed(ownerDid: string, handle: string, shareId: string) { 42 + if (!burnPool) return false; 43 + await ensureBurnTable(); 44 + const result = await burnPool.query( 45 + "SELECT 1 FROM atproto_share_burn_reads WHERE (owner_did = $1 OR (owner_did IS NULL AND handle = $2)) AND share_id = $3 LIMIT 1", 46 + [ownerDid, handle, shareId], 47 + ); 48 + return result.rowCount > 0; 49 + } 50 + 51 + async function markPublicBurnConsumed(ownerDid: string, handle: string, shareId: string) { 52 + if (!burnPool) return; 53 + await ensureBurnTable(); 54 + await burnPool.query( 55 + ` 56 + INSERT INTO atproto_share_burn_reads (handle, owner_did, share_id, pds_delete_synced) 57 + VALUES ($1, $2, $3, FALSE) 58 + ON CONFLICT (share_id, handle) 59 + DO UPDATE SET owner_did = EXCLUDED.owner_did, pds_delete_synced = FALSE, burned_at = NOW() 60 + `, 61 + [handle, ownerDid, shareId], 62 + ); 63 + } 64 + 65 + function keyV2Unprotected(shareId: string) { 66 + return crypto.createHash("sha256").update(shareId, "utf8").digest(); 67 + } 68 + 69 + function keyV3Password(password: string, shareId: string) { 70 + return crypto.scryptSync(password, shareId, 32); 71 + } 72 + 73 + function decryptWithKey(encryptedData: string, iv: string, authTag: string, key: Buffer): string { 74 + const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, "hex")); 75 + decipher.setAuthTag(Buffer.from(authTag, "hex")); 76 + let decrypted = decipher.update(encryptedData, "hex", "utf8"); 77 + decrypted += decipher.final("utf8"); 78 + return decrypted; 79 + } 80 + 81 + export async function GET(request: NextRequest) { 82 + const handle = request.nextUrl.searchParams.get("handle"); 83 + const id = request.nextUrl.searchParams.get("id"); 84 + const password = request.nextUrl.searchParams.get("password") ?? ""; 85 + 86 + if (!handle || !id) return NextResponse.json({ error: "Missing handle or id" }, { status: 400 }); 87 + 88 + const normalizedHandle = handle.replace(/^@/, "").toLowerCase(); 89 + const resolved = await resolveHandle(normalizedHandle); 90 + const record = await getRecord({ pds: resolved.pds, repo: resolved.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id }); 91 + const value = record.value ?? {}; 92 + const isProtected = !!value.isProtected; 93 + const isBurn = !!value.isBurn; 94 + 95 + if (isBurn && (await wasPublicBurnConsumed(resolved.did, normalizedHandle, id))) { 96 + return NextResponse.json({ error: "Share not found" }, { status: 404 }); 97 + } 98 + 99 + if (isProtected && !password) { 100 + return NextResponse.json({ error: "Password required", requiresPassword: true, burnAfterRead: isBurn }, { status: 401 }); 101 + } 102 + 103 + const key = isProtected ? keyV3Password(password, id) : keyV2Unprotected(id); 104 + try { 105 + const decryptedData = decryptWithKey(String(value.encryptedData), String(value.iv), String(value.authTag), key); 106 + 107 + if (isBurn) { 108 + await markPublicBurnConsumed(resolved.did, normalizedHandle, id); 109 + } 110 + 111 + return NextResponse.json({ success: true, data: decryptedData, protected: isProtected, burnAfterRead: isBurn, timestamp: value.timestamp }); 112 + } catch { 113 + return NextResponse.json({ error: isProtected ? "Invalid password" : "Failed to decrypt" }, { status: 403 }); 114 + } 115 + }
+95 -143
app/api/share/route.ts
··· 2 2 import { currentUser } from "@clerk/nextjs/server"; 3 3 import { Pool } from "pg"; 4 4 import crypto from "crypto"; 5 + import { deleteRecord, getRecord, putRecord } from "@/lib/atproto"; 6 + import { getAtprotoSession } from "@/lib/atproto-auth"; 5 7 6 8 const pool = new Pool({ 7 9 connectionString: process.env.POSTGRES_URL, ··· 38 40 } 39 41 40 42 const ALGORITHM = "aes-256-gcm"; 43 + const ATPROTO_SHARE_COLLECTION = "app.onecalendar.share"; 41 44 42 45 function keyV2Unprotected(shareId: string) { 43 46 return crypto.createHash("sha256").update(shareId, "utf8").digest(); ··· 47 50 return crypto.scryptSync(password, shareId, 32); 48 51 } 49 52 50 - function keyV1Legacy(shareId: string) { 51 - const salt = process.env.SALT; 52 - if (!salt) throw new Error("SALT environment variable is not set"); 53 - return crypto.scryptSync(shareId, salt, 32); 54 - } 55 - 56 53 function encryptWithKey(data: string, key: Buffer): { encryptedData: string; iv: string; authTag: string } { 57 54 const iv = crypto.randomBytes(16); 58 55 const cipher = crypto.createCipheriv(ALGORITHM, key, iv); ··· 74 71 75 72 export async function POST(request: NextRequest) { 76 73 try { 77 - const user = await currentUser() 78 - if (!user) { 79 - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 80 - } 81 74 const body = await request.json(); 82 75 const { id, data, password, burnAfterRead } = body as { 83 76 id?: string; 84 - data?: any; 77 + data?: unknown; 85 78 password?: string; 86 79 burnAfterRead?: boolean; 87 80 }; ··· 92 85 93 86 const hasPassword = typeof password === "string" && password.length > 0; 94 87 const burn = !!burnAfterRead; 88 + const dataString = typeof data === "string" ? data : JSON.stringify(data); 89 + const key = hasPassword ? keyV3Password(password as string, id) : keyV2Unprotected(id); 90 + const { encryptedData, iv, authTag } = encryptWithKey(dataString, key); 95 91 96 - if (burn && !hasPassword) { 97 - return NextResponse.json({ error: "burnAfterRead requires password protection" }, { status: 400 }); 92 + const atproto = await getAtprotoSession(); 93 + if (atproto) { 94 + await putRecord({ 95 + pds: atproto.pds, 96 + repo: atproto.did, 97 + collection: ATPROTO_SHARE_COLLECTION, 98 + rkey: id, 99 + accessToken: atproto.accessToken, 100 + dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, 101 + dpopPublicJwk: atproto.dpopPublicJwk, 102 + record: { 103 + $type: ATPROTO_SHARE_COLLECTION, 104 + encryptedData, 105 + iv, 106 + authTag, 107 + isProtected: hasPassword, 108 + isBurn: burn, 109 + timestamp: new Date().toISOString(), 110 + }, 111 + }); 112 + 113 + return NextResponse.json({ success: true, id, protected: hasPassword, burnAfterRead: burn, shareLink: `/${atproto.handle}/${id}` }); 98 114 } 99 115 100 - const POSTGRES_URL = process.env.POSTGRES_URL; 101 - if (!POSTGRES_URL) throw new Error("POSTGRES_URL is not set"); 116 + const user = await currentUser(); 117 + if (!user) { 118 + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 119 + } 102 120 103 121 await initializeDatabase(); 104 - 105 - const dataString = typeof data === "string" ? data : JSON.stringify(data); 106 - const encVersion = hasPassword ? 3 : 2; 107 - const key = hasPassword ? keyV3Password(password as string, id) : keyV2Unprotected(id); 108 - const { encryptedData, iv, authTag } = encryptWithKey(dataString, key); 109 122 110 123 const client = await pool.connect(); 111 124 try { ··· 114 127 INSERT INTO shares (user_id, share_id, encrypted_data, iv, auth_tag, timestamp, is_protected, is_burn, enc_version) 115 128 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 116 129 ON CONFLICT (share_id) 117 - DO UPDATE SET 118 - encrypted_data = EXCLUDED.encrypted_data, 119 - iv = EXCLUDED.iv, 120 - auth_tag = EXCLUDED.auth_tag, 121 - timestamp = EXCLUDED.timestamp, 122 - is_protected = EXCLUDED.is_protected, 123 - is_burn = EXCLUDED.is_burn, 124 - enc_version = EXCLUDED.enc_version, 125 - user_id = EXCLUDED.user_id 130 + DO UPDATE SET encrypted_data = EXCLUDED.encrypted_data, iv = EXCLUDED.iv, auth_tag = EXCLUDED.auth_tag, 131 + timestamp = EXCLUDED.timestamp, is_protected = EXCLUDED.is_protected, is_burn = EXCLUDED.is_burn, 132 + enc_version = EXCLUDED.enc_version, user_id = EXCLUDED.user_id 126 133 `, 127 - [user.id, id, encryptedData, iv, authTag, new Date().toISOString(), hasPassword, burn, encVersion] 134 + [user.id, id, encryptedData, iv, authTag, new Date().toISOString(), hasPassword, burn, hasPassword ? 3 : 2], 128 135 ); 129 136 130 - return NextResponse.json({ 131 - success: true, 132 - path: `shares/${id}/data.json`, 133 - id, 134 - message: "Share created successfully.", 135 - protected: hasPassword, 136 - burnAfterRead: burn, 137 - }); 137 + return NextResponse.json({ success: true, id, protected: hasPassword, burnAfterRead: burn, shareLink: `/share/${id}` }); 138 138 } finally { 139 139 client.release(); 140 140 } 141 141 } catch (error) { 142 - return NextResponse.json( 143 - { 144 - error: error instanceof Error ? error.message : "Unknown error occurred", 145 - stack: error instanceof Error ? error.stack : undefined, 146 - }, 147 - { status: 500 } 148 - ); 142 + return NextResponse.json({ error: error instanceof Error ? error.message : "Unknown error occurred" }, { status: 500 }); 143 + } 144 + } 145 + 146 + async function getAtprotoShare(id: string, password: string, handleParam?: string) { 147 + const atproto = await getAtprotoSession(); 148 + if (atproto) { 149 + const record = await getRecord({ pds: atproto.pds, repo: atproto.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id, accessToken: atproto.accessToken, dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, dpopPublicJwk: atproto.dpopPublicJwk }); 150 + const value = record.value ?? {}; 151 + const isProtected = !!value.isProtected; 152 + if (isProtected && !password) { 153 + return NextResponse.json({ error: "Password required", requiresPassword: true, burnAfterRead: value.isBurn }, { status: 401 }); 154 + } 155 + const key = isProtected ? keyV3Password(password, id) : keyV2Unprotected(id); 156 + const decryptedData = decryptWithKey(String(value.encryptedData), String(value.iv), String(value.authTag), key); 157 + 158 + if (value.isBurn) { 159 + await deleteRecord({ pds: atproto.pds, repo: atproto.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id, accessToken: atproto.accessToken, dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, dpopPublicJwk: atproto.dpopPublicJwk }); 160 + } 161 + 162 + return NextResponse.json({ success: true, data: decryptedData, timestamp: value.timestamp, protected: isProtected, burnAfterRead: !!value.isBurn }); 163 + } 164 + 165 + if (handleParam) { 166 + return NextResponse.json({ error: "Public atproto retrieval requires owner session support not configured" }, { status: 400 }); 149 167 } 168 + 169 + return null; 150 170 } 151 171 152 172 export async function GET(request: NextRequest) { 153 173 const id = request.nextUrl.searchParams.get("id"); 154 174 const password = request.nextUrl.searchParams.get("password") ?? ""; 175 + const handle = request.nextUrl.searchParams.get("handle") ?? undefined; 155 176 156 - if (!id) { 157 - return NextResponse.json({ error: "Missing share ID" }, { status: 400 }); 158 - } 177 + if (!id) return NextResponse.json({ error: "Missing share ID" }, { status: 400 }); 159 178 160 179 try { 161 - const POSTGRES_URL = process.env.POSTGRES_URL; 162 - if (!POSTGRES_URL) throw new Error("POSTGRES_URL is not set"); 180 + const atprotoResult = await getAtprotoShare(id, password, handle); 181 + if (atprotoResult) return atprotoResult; 163 182 164 183 await initializeDatabase(); 165 - 166 184 const client = await pool.connect(); 167 185 try { 168 186 await client.query("BEGIN"); 169 - 170 187 const result = await client.query( 171 - "SELECT encrypted_data, iv, auth_tag, timestamp, is_protected, is_burn, enc_version FROM shares WHERE share_id = $1 FOR UPDATE", 172 - [id] 188 + "SELECT encrypted_data, iv, auth_tag, timestamp, is_protected, is_burn FROM shares WHERE share_id = $1 FOR UPDATE", 189 + [id], 173 190 ); 174 - 175 191 if (result.rows.length === 0) { 176 192 await client.query("ROLLBACK"); 177 193 return NextResponse.json({ error: "Share not found" }, { status: 404 }); 178 194 } 179 - 180 - const row = result.rows[0] as { 181 - encrypted_data: string; 182 - iv: string; 183 - auth_tag: string; 184 - timestamp: Date; 185 - is_protected: boolean; 186 - is_burn: boolean; 187 - enc_version: number | null; 188 - }; 189 - 195 + const row = result.rows[0]; 190 196 if (row.is_protected && !password) { 191 197 await client.query("COMMIT"); 192 - return NextResponse.json( 193 - { error: "Password required", requiresPassword: true, burnAfterRead: row.is_burn }, 194 - { status: 401 } 195 - ); 196 - } 197 - 198 - const encVersion = row.enc_version ?? 1; 199 - 200 - let key: Buffer; 201 - if (row.is_protected) { 202 - key = keyV3Password(password, id); 203 - } else { 204 - key = encVersion === 1 ? keyV1Legacy(id) : keyV2Unprotected(id); 198 + return NextResponse.json({ error: "Password required", requiresPassword: true, burnAfterRead: row.is_burn }, { status: 401 }); 205 199 } 206 - 200 + const key = row.is_protected ? keyV3Password(password, id) : keyV2Unprotected(id); 207 201 let decryptedData: string; 208 202 try { 209 203 decryptedData = decryptWithKey(row.encrypted_data, row.iv, row.auth_tag, key); 210 204 } catch { 211 205 await client.query("COMMIT"); 212 - if (row.is_protected) return NextResponse.json({ error: "Invalid password" }, { status: 403 }); 213 - return NextResponse.json({ error: "Failed to decrypt share data." }, { status: 403 }); 206 + return NextResponse.json({ error: row.is_protected ? "Invalid password" : "Failed to decrypt share data." }, { status: 403 }); 214 207 } 215 - 216 208 if (row.is_burn) { 217 209 await client.query("DELETE FROM shares WHERE share_id = $1", [id]); 218 210 } 219 - 220 211 await client.query("COMMIT"); 221 - 222 - return NextResponse.json({ 223 - success: true, 224 - data: decryptedData, 225 - timestamp: row.timestamp.toISOString(), 226 - protected: row.is_protected, 227 - burnAfterRead: row.is_burn, 228 - }); 212 + return NextResponse.json({ success: true, data: decryptedData, timestamp: row.timestamp.toISOString(), protected: row.is_protected, burnAfterRead: row.is_burn }); 229 213 } catch (e) { 230 - try { 231 - await client.query("ROLLBACK"); 232 - } catch {} 214 + await client.query("ROLLBACK"); 233 215 throw e; 234 216 } finally { 235 217 client.release(); 236 218 } 237 219 } catch (error) { 238 - return NextResponse.json( 239 - { 240 - error: error instanceof Error ? error.message : "Unknown error occurred", 241 - stack: error instanceof Error ? error.stack : undefined, 242 - }, 243 - { status: 500 } 244 - ); 220 + return NextResponse.json({ error: error instanceof Error ? error.message : "Unknown error occurred" }, { status: 500 }); 245 221 } 246 222 } 247 223 248 224 export async function DELETE(request: NextRequest) { 249 - try { 250 - const user = await currentUser() 251 - if (!user) { 252 - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 253 - } 254 - 255 - const body = await request.json(); 256 - const { id } = body as { id?: string }; 225 + const body = await request.json(); 226 + const { id } = body as { id?: string }; 227 + if (!id) return NextResponse.json({ error: "Missing share ID" }, { status: 400 }); 257 228 258 - if (!id) { 259 - return NextResponse.json({ error: "Missing share ID" }, { status: 400 }); 260 - } 229 + const atproto = await getAtprotoSession(); 230 + if (atproto) { 231 + await deleteRecord({ pds: atproto.pds, repo: atproto.did, collection: ATPROTO_SHARE_COLLECTION, rkey: id, accessToken: atproto.accessToken, dpopPrivateKeyPem: atproto.dpopPrivateKeyPem, dpopPublicJwk: atproto.dpopPublicJwk }); 232 + return NextResponse.json({ success: true }); 233 + } 261 234 262 - const POSTGRES_URL = process.env.POSTGRES_URL; 263 - if (!POSTGRES_URL) throw new Error("POSTGRES_URL is not set"); 235 + const user = await currentUser(); 236 + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 264 237 265 - await initializeDatabase(); 266 - 267 - const client = await pool.connect(); 268 - try { 269 - const result = await client.query("DELETE FROM shares WHERE share_id = $1 AND user_id = $2 RETURNING *", [id, user.id]); 270 - 271 - if (result.rowCount === 0) { 272 - return NextResponse.json({ 273 - success: true, 274 - message: `No share found with ID: ${id}, nothing to delete.`, 275 - }); 276 - } 277 - 278 - return NextResponse.json({ 279 - success: true, 280 - message: `Successfully deleted share with ID: ${id}`, 281 - }); 282 - } finally { 283 - client.release(); 284 - } 285 - } catch (error) { 286 - return NextResponse.json( 287 - { 288 - error: error instanceof Error ? error.message : "Unknown error occurred", 289 - stack: error instanceof Error ? error.stack : undefined, 290 - }, 291 - { status: 500 } 292 - ); 238 + await initializeDatabase(); 239 + const client = await pool.connect(); 240 + try { 241 + await client.query("DELETE FROM shares WHERE share_id = $1 AND user_id = $2", [id, user.id]); 242 + return NextResponse.json({ success: true }); 243 + } finally { 244 + client.release(); 293 245 } 294 246 }
+32
app/at-oauth/page.tsx
··· 1 + import { AuthBrand } from "@/components/auth/auth-brand"; 2 + import { AtprotoLoginForm } from "@/components/auth/atproto-login-form"; 3 + 4 + export default function AtprotoLoginPage() { 5 + return ( 6 + <div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden from-blue-500 via-indigo-500 to-purple-500 p-6 md:p-10"> 7 + <div className="fixed -z-10 inset-0"> 8 + <div className="absolute inset-0 bg-white dark:bg-black"> 9 + <div 10 + className="absolute inset-0" 11 + style={{ 12 + backgroundImage: `radial-gradient(circle at 1px 1px, rgba(0, 0, 0, 0.1) 1px, transparent 0)`, 13 + backgroundSize: "24px 24px", 14 + }} 15 + /> 16 + <div 17 + className="absolute inset-0 dark:block hidden" 18 + style={{ 19 + backgroundImage: `radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.15) 1px, transparent 0)`, 20 + backgroundSize: "24px 24px", 21 + }} 22 + /> 23 + </div> 24 + </div> 25 + 26 + <div className="relative z-10 flex w-full max-w-sm flex-col gap-6"> 27 + <AuthBrand /> 28 + <AtprotoLoginForm /> 29 + </div> 30 + </div> 31 + ); 32 + }
+4 -3
components/app/calendar.tsx
··· 425 425 setEvents((prevEvents) => [...prevEvents, ...newEvents]); 426 426 }; 427 427 428 - const handleEventEdit = () => { 429 - if (previewEvent) { 430 - setSelectedEvent(previewEvent); 428 + const handleEventEdit = (event?: CalendarEvent) => { 429 + const targetEvent = event ?? previewEvent; 430 + if (targetEvent) { 431 + setSelectedEvent(targetEvent); 431 432 setQuickCreateStartTime(null); 432 433 setEventDialogOpen(true); 433 434 setPreviewOpen(false);
+22 -10
components/app/event/event-preview.tsx
··· 72 72 const [qrCodeDataURL, setQRCodeDataURL] = useState<string>(""); 73 73 const qrCodeObjectURLRef = useRef<string | null>(null); 74 74 const { isSignedIn, user } = useUser(); 75 + const [atprotoSignedIn, setAtprotoSignedIn] = useState(false); 76 + const [atprotoHandle, setAtprotoHandle] = useState(""); 75 77 const dialogContentRef = useRef<HTMLDivElement>(null); 76 78 const [bookmarks, setBookmarks] = useState<any[]>([]); 77 79 const [passwordEnabled, setPasswordEnabled] = useState(false); ··· 92 94 93 95 useEffect(() => { 94 96 if (open && openShareImmediately) { 95 - if (!isSignedIn) { 97 + if (!isSignedIn && !atprotoSignedIn) { 96 98 toast.error(t.shareSignInRequiredTitle, { 97 99 description: t.shareSignInRequiredDescription, 98 100 }); ··· 100 102 setShareDialogOpen(true); 101 103 } 102 104 } 103 - }, [open, openShareImmediately, isSignedIn, language]); 105 + }, [open, openShareImmediately, isSignedIn, atprotoSignedIn, language]); 104 106 107 + 108 + useEffect(() => { 109 + fetch("/api/atproto/session") 110 + .then((r) => r.json()) 111 + .then((data: { signedIn?: boolean; handle?: string }) => { 112 + setAtprotoSignedIn(!!data.signedIn); 113 + setAtprotoHandle(data.handle || ""); 114 + }) 115 + .catch(() => undefined); 116 + }, []); 105 117 useEffect(() => { 106 118 let active = true; 107 119 const loadBookmarks = () => ··· 243 255 244 256 const handleShare = async () => { 245 257 if (!event) return; 246 - if (!user) { 258 + if (!user && !atprotoSignedIn) { 247 259 toast.error(t.shareSignInRequiredTitle, { 248 260 description: t.shareSignedInOnlyDescription, 249 261 }); ··· 265 277 try { 266 278 setIsSharing(true); 267 279 const shareId = Date.now().toString() + Math.random().toString(36).substring(2, 9); 268 - const clerkUsername = user.username || user.firstName || "Anonymous"; 280 + const clerkUsername = (user?.username || user?.firstName || atprotoHandle || "Anonymous"); 269 281 const sharedEvent = { ...event, sharedBy: clerkUsername }; 270 282 271 283 const payload: any = { id: shareId, data: sharedEvent }; ··· 286 298 const result = await response.json(); 287 299 288 300 if (result.success) { 289 - const link = `${window.location.origin}/share/${shareId}`; 301 + const link = result?.shareLink ? `${window.location.origin}${result.shareLink}` : `${window.location.origin}/share/${shareId}`; 290 302 setShareLink(link); 291 303 292 304 try { ··· 452 464 <div className="flex justify-between items-center p-5"> 453 465 <div className="w-24"></div> 454 466 <div className="flex space-x-2 ml-auto"> 455 - <Button variant="ghost" size="icon" onClick={onEdit} className="h-8 w-8"> 467 + <Button variant="ghost" size="icon" onClick={handleDeleteClick} className="h-8 w-8 text-red-600 hover:text-red-600"> 468 + <Trash2 className="h-5 w-5" /> 469 + </Button> 470 + <Button variant="ghost" size="icon" onClick={() => onEdit()} className="h-8 w-8"> 456 471 <Edit2 className="h-5 w-5" /> 457 472 </Button> 458 473 <Button ··· 460 475 size="icon" 461 476 onClick={(e) => { 462 477 e.stopPropagation(); 463 - if (!isSignedIn) { 478 + if (!isSignedIn && !atprotoSignedIn) { 464 479 toast.error(t.shareSignInRequiredTitle, { 465 480 description: t.shareSignInRequiredDescription, 466 481 }); ··· 474 489 </Button> 475 490 <Button variant="ghost" size="icon" onClick={toggleBookmark} className="h-8 w-8"> 476 491 <Bookmark className={cn("h-5 w-5", isBookmarked ? "fill-blue-500 text-blue-500" : "")} /> 477 - </Button> 478 - <Button variant="ghost" size="icon" onClick={handleDeleteClick} className="h-8 w-8"> 479 - <Trash2 className="h-5 w-5" /> 480 492 </Button> 481 493 <Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} className="h-8 w-8 ml-2"> 482 494 <X className="h-5 w-5" />
+13 -8
components/app/profile/shared-event.tsx
··· 50 50 51 51 interface SharedEventViewProps { 52 52 shareId: string; 53 + handle?: string; 53 54 } 54 55 55 56 function getDarkerColorClass(color: string) { ··· 68 69 return colorMapping[color] || '#3A3A3A'; 69 70 } 70 71 71 - export default function SharedEventView({ shareId }: SharedEventViewProps) { 72 + export default function SharedEventView({ shareId, handle }: SharedEventViewProps) { 72 73 const router = useRouter(); 73 74 const { toast } = useToast(); 74 75 const [language] = useLanguage(); ··· 105 106 return; 106 107 } 107 108 108 - const response = await fetch(`/api/share?id=${encodeURIComponent(shareId)}`); 109 + const response = await fetch(handle 110 + ? `/api/share/public?handle=${encodeURIComponent(handle)}&id=${encodeURIComponent(shareId)}` 111 + : `/api/share?id=${encodeURIComponent(shareId)}`); 109 112 110 113 if (!response.ok) { 111 114 if (response.status === 401) { ··· 137 140 }; 138 141 139 142 fetchSharedEvent(); 140 - }, [shareId]); 143 + }, [shareId, handle]); 141 144 142 145 const tryDecryptWithPassword = async () => { 143 146 if (!shareId) return; ··· 152 155 setPasswordSubmitting(true); 153 156 setPasswordError(null); 154 157 155 - const url = `/api/share?id=${encodeURIComponent(shareId)}&password=${encodeURIComponent(pwd)}`; 158 + const url = handle 159 + ? `/api/share/public?handle=${encodeURIComponent(handle)}&id=${encodeURIComponent(shareId)}&password=${encodeURIComponent(pwd)}` 160 + : `/api/share?id=${encodeURIComponent(shareId)}&password=${encodeURIComponent(pwd)}`; 156 161 const response = await fetch(url); 157 162 158 163 if (!response.ok) { ··· 483 488 </a> 484 489 485 490 <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}> 486 - <Card className="max-w-md w-full overflow-hidden"> 487 - <div className="relative"> 488 - <div className={cn("absolute left-0 top-0 h-full w-1")} style={{ backgroundColor: getDarkerColorClass(event.color) }}/> 491 + <Card className="relative max-w-md w-full overflow-hidden"> 492 + <div className={cn("absolute inset-y-0 left-0 w-1")} style={{ backgroundColor: getDarkerColorClass(event.color) }}/> 493 + <div> 489 494 <CardContent className="p-6"> 490 495 <div className="flex justify-between items-start mb-4"> 491 496 <div> 492 497 <CardTitle className="text-2xl font-bold mb-1">{event.title}</CardTitle> 493 498 <CardDescription> 494 499 {isZh ? "ๅˆ†ไบซ่€…๏ผš" : "Shared by: "} 495 - <span className="font-medium">{event.sharedBy}</span> 500 + <a className="font-medium underline underline-offset-4" href={`https://bsky.app/profile/${String(event.sharedBy || "").replace(/^@/, "")}`} target="_blank" rel="noreferrer">{event.sharedBy}</a> 496 501 </CardDescription> 497 502 {burnAfterRead && ( 498 503 <div className="mt-2 inline-flex items-center gap-2 text-sm text-red-500">
+95 -43
components/app/profile/user-profile-button.tsx
··· 185 185 const { events, calendars, setEvents, setCalendars } = useCalendar() 186 186 const { user, isSignedIn } = useUser() 187 187 const router = useRouter() 188 + const [atprotoHandle, setAtprotoHandle] = useState("") 189 + const [atprotoDisplayName, setAtprotoDisplayName] = useState("") 190 + const [atprotoAvatar, setAtprotoAvatar] = useState("") 191 + const [atprotoSignedIn, setAtprotoSignedIn] = useState(false) 192 + const isAnySignedIn = isSignedIn || atprotoSignedIn 188 193 189 194 const [enabled, setEnabled] = useState(false) 190 195 const [profileOpen, setProfileOpen] = useState(false) ··· 214 219 const timerRef = useRef<any>(null) 215 220 216 221 useEffect(() => { 222 + fetch("/api/atproto/session") 223 + .then((r) => r.json()) 224 + .then((data: { signedIn?: boolean; handle?: string; displayName?: string; avatar?: string }) => { 225 + setAtprotoSignedIn(!!data.signedIn) 226 + setAtprotoHandle(data.handle || "") 227 + setAtprotoDisplayName(data.displayName || "") 228 + setAtprotoAvatar(data.avatar || "") 229 + }) 230 + .catch(() => undefined) 231 + }, []) 232 + 233 + useEffect(() => { 217 234 if (mode !== "settings" || !focusSection) return 218 235 const target = document.getElementById(`settings-account-${focusSection}`) 219 236 target?.scrollIntoView({ behavior: "smooth", block: "center" }) ··· 237 254 238 255 useEffect(() => { 239 256 if (mode === "settings") return 240 - if (!isSignedIn || keyRef.current || restoredRef.current) return 257 + if (!isAnySignedIn || keyRef.current || restoredRef.current) return 241 258 apiGet().then((cloud) => { 242 259 if (cloud) setUnlockOpen(true) 243 260 }) 244 - }, [isSignedIn, mode]) 261 + }, [isAnySignedIn, mode]) 245 262 246 263 useEffect(() => { 247 264 if (!enabled || !keyRef.current || !restoredRef.current) return ··· 506 523 {mode === "dropdown" ? ( 507 524 <DropdownMenu> 508 525 <DropdownMenuTrigger asChild> 509 - {isSignedIn && user?.imageUrl ? ( 526 + {(isSignedIn && user?.imageUrl) || (!isSignedIn && atprotoSignedIn && atprotoAvatar) ? ( 510 527 <Button variant="ghost" size="icon" className="rounded-full overflow-hidden h-8 w-8 p-0"> 511 528 <img 512 - src={user.imageUrl} 529 + src={isSignedIn ? user.imageUrl : atprotoAvatar} 513 530 alt="avatar" 514 531 width={32} 515 532 height={32} ··· 526 543 </DropdownMenuTrigger> 527 544 528 545 <DropdownMenuContent align="end"> 529 - {isSignedIn ? ( 546 + {isAnySignedIn ? ( 530 547 <> 531 - <DropdownMenuItem onClick={() => onNavigateToSettings ? onNavigateToSettings("profile") : setProfileOpen(true)}> 532 - <CircleUser className="mr-2 h-4 w-4" /> 533 - {t.profile} 534 - </DropdownMenuItem> 548 + {isSignedIn ? ( 549 + <DropdownMenuItem onClick={() => onNavigateToSettings ? onNavigateToSettings("profile") : setProfileOpen(true)}> 550 + <CircleUser className="mr-2 h-4 w-4" /> 551 + {t.profile} 552 + </DropdownMenuItem> 553 + ) : null} 535 554 536 555 <DropdownMenuItem onClick={() => onNavigateToSettings ? onNavigateToSettings("backup") : setBackupOpen(true)}> 537 556 <CloudUpload className="mr-2 h-4 w-4" /> ··· 548 567 {t.deleteData} 549 568 </DropdownMenuItem> 550 569 551 - <DropdownMenuItem onClick={() => setDeleteAccountOpen(true)} className="text-red-600 focus:text-red-600"> 552 - <Trash2 className="mr-2 h-4 w-4" /> 553 - {t.deleteAccount} 554 - </DropdownMenuItem> 555 - 556 - {onNavigateToSettings ? ( 557 - <DropdownMenuItem onClick={() => onNavigateToSettings("signout")}> 558 - <LogOut className="mr-2 h-4 w-4" /> 559 - {t.signOut} 570 + {isSignedIn ? ( 571 + <DropdownMenuItem onClick={() => setDeleteAccountOpen(true)} className="text-red-600 focus:text-red-600"> 572 + <Trash2 className="mr-2 h-4 w-4" /> 573 + {t.deleteAccount} 560 574 </DropdownMenuItem> 561 - ) : ( 562 - <SignOutButton> 563 - <DropdownMenuItem> 575 + ) : null} 576 + 577 + {isSignedIn ? ( 578 + onNavigateToSettings ? ( 579 + <DropdownMenuItem onClick={() => onNavigateToSettings("signout")}> 564 580 <LogOut className="mr-2 h-4 w-4" /> 565 581 {t.signOut} 566 582 </DropdownMenuItem> 567 - </SignOutButton> 583 + ) : ( 584 + <SignOutButton> 585 + <DropdownMenuItem> 586 + <LogOut className="mr-2 h-4 w-4" /> 587 + {t.signOut} 588 + </DropdownMenuItem> 589 + </SignOutButton> 590 + ) 591 + ) : ( 592 + <DropdownMenuItem onClick={async () => { 593 + await fetch("/api/atproto/logout", { method: "POST" }) 594 + setAtprotoSignedIn(false) 595 + setAtprotoHandle("") 596 + setAtprotoDisplayName("") 597 + setAtprotoAvatar("") 598 + router.refresh() 599 + }}> 600 + <LogOut className="mr-2 h-4 w-4" /> 601 + {t.signOut} 602 + </DropdownMenuItem> 568 603 )} 569 604 </> 570 605 ) : ( ··· 577 612 </DropdownMenu> 578 613 ) : ( 579 614 <div className="rounded-lg border p-4 space-y-4"> 580 - {isSignedIn ? ( 615 + {isAnySignedIn ? ( 581 616 <> 582 617 <div className="flex items-center gap-3"> 583 618 <img 584 - src={user?.imageUrl || "/placeholder.svg"} 619 + src={user?.imageUrl || atprotoAvatar || "/placeholder.svg"} 585 620 alt="avatar" 586 621 width={40} 587 622 height={40} ··· 590 625 referrerPolicy="no-referrer" 591 626 /> 592 627 <div className="min-w-0"> 593 - <p className="font-medium truncate">{[user?.firstName, user?.lastName].filter(Boolean).join(" ") || user?.username || "User"}</p> 594 - <p className="text-sm text-muted-foreground truncate">{user?.primaryEmailAddress?.emailAddress}</p> 628 + <p className="font-medium truncate">{[user?.firstName, user?.lastName].filter(Boolean).join(" ") || user?.username || atprotoDisplayName || atprotoHandle || "User"}</p> 629 + <p className="text-sm text-muted-foreground truncate">{user?.primaryEmailAddress?.emailAddress || (atprotoHandle ? `@${atprotoHandle}` : "")}</p> 595 630 </div> 596 631 </div> 597 632 <div className="space-y-4"> 598 - <div className="space-y-3 rounded-md border p-3"> 599 - <p className="text-sm font-semibold">{t.basicInfo}</p> 600 - <p className="text-xs text-muted-foreground">{t.editProfileDescription}</p> 601 - <Button id="settings-account-profile" variant="outline" onClick={() => openProfileSection("basic")}><CircleUser className="h-4 w-4 mr-2" />{t.openBasicInfo}</Button> 602 - </div> 633 + {isSignedIn ? ( 634 + <> 635 + <div className="space-y-3 rounded-md border p-3"> 636 + <p className="text-sm font-semibold">{t.basicInfo}</p> 637 + <p className="text-xs text-muted-foreground">{t.editProfileDescription}</p> 638 + <Button id="settings-account-profile" variant="outline" onClick={() => openProfileSection("basic")}><CircleUser className="h-4 w-4 mr-2" />{t.openBasicInfo}</Button> 639 + </div> 603 640 604 - <div className="space-y-3 rounded-md border p-3"> 605 - <p className="text-sm font-semibold">{t.emailManagement}</p> 606 - <p className="text-xs text-muted-foreground">{t.manageEmailAddressesDescription}</p> 607 - <Button variant="outline" onClick={() => openProfileSection("emails")}><Mail className="h-4 w-4 mr-2" />{t.openEmailSettings}</Button> 608 - </div> 641 + <div className="space-y-3 rounded-md border p-3"> 642 + <p className="text-sm font-semibold">{t.emailManagement}</p> 643 + <p className="text-xs text-muted-foreground">{t.manageEmailAddressesDescription}</p> 644 + <Button variant="outline" onClick={() => openProfileSection("emails")}><Mail className="h-4 w-4 mr-2" />{t.openEmailSettings}</Button> 645 + </div> 609 646 610 - <div className="space-y-3 rounded-md border p-3"> 611 - <p className="text-sm font-semibold">OAuth</p> 612 - <p className="text-xs text-muted-foreground">{t.manageOauthDescription}</p> 613 - <Button variant="outline" onClick={() => openProfileSection("oauth")}><LinkIcon className="h-4 w-4 mr-2" />{t.openOauthSettings}</Button> 614 - </div> 647 + <div className="space-y-3 rounded-md border p-3"> 648 + <p className="text-sm font-semibold">OAuth</p> 649 + <p className="text-xs text-muted-foreground">{t.manageOauthDescription}</p> 650 + <Button variant="outline" onClick={() => openProfileSection("oauth")}><LinkIcon className="h-4 w-4 mr-2" />{t.openOauthSettings}</Button> 651 + </div> 652 + </> 653 + ) : null} 615 654 616 655 <div className="space-y-3 rounded-md border p-3"> 617 656 <p className="text-sm font-semibold">{t.autoBackup}</p> ··· 628 667 <div className="space-y-3 rounded-md border p-3"> 629 668 <p className="text-sm font-semibold">{t.signOut}</p> 630 669 <p className="text-xs text-muted-foreground">{t.signOutHelp}</p> 670 + {isSignedIn ? ( 631 671 <SignOutButton> 632 672 <Button id="settings-account-signout" variant="outline"><LogOut className="h-4 w-4 mr-2" />{t.signOut}</Button> 633 673 </SignOutButton> 674 + ) : ( 675 + <Button id="settings-account-signout" variant="outline" onClick={async () => { 676 + await fetch("/api/atproto/logout", { method: "POST" }) 677 + setAtprotoSignedIn(false) 678 + setAtprotoHandle("") 679 + setAtprotoDisplayName("") 680 + setAtprotoAvatar("") 681 + router.refresh() 682 + }}><LogOut className="h-4 w-4 mr-2" />{t.signOut}</Button> 683 + )} 634 684 </div> 635 685 636 686 <div className="rounded-md border border-destructive/70 p-3 space-y-3 bg-destructive/5"> ··· 640 690 <p className="text-xs text-muted-foreground">{t.deleteAccountDataHelp}</p> 641 691 <Button id="settings-account-delete" variant="destructive" onClick={destroy}><Trash2 className="h-4 w-4 mr-2" />{t.deleteData}</Button> 642 692 </div> 643 - <div className="space-y-3 rounded-md border border-destructive/50 p-3"> 693 + {isSignedIn ? ( 694 + <div className="space-y-3 rounded-md border border-destructive/50 p-3"> 644 695 <p className="text-sm font-semibold text-destructive">{t.deleteAccount}</p> 645 696 <p className="text-xs text-muted-foreground">{t.deleteAccountPermanentHelp}</p> 646 697 <Button variant="destructive" onClick={() => setDeleteAccountOpen(true)}> ··· 648 699 {t.deleteAccount} 649 700 </Button> 650 701 </div> 702 + ) : null} 651 703 </div> 652 704 </div> 653 705 </> ··· 677 729 <Label>{t.avatar}</Label> 678 730 <div className="flex items-center gap-3"> 679 731 <img 680 - src={user?.imageUrl || "/placeholder.svg"} 732 + src={user?.imageUrl || atprotoAvatar || "/placeholder.svg"} 681 733 alt="avatar" 682 734 width={52} 683 735 height={52}
+109
components/auth/atproto-login-form.tsx
··· 1 + "use client"; 2 + 3 + import { Suspense, useState } from "react"; 4 + import { useSearchParams } from "next/navigation"; 5 + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 6 + import { Input } from "@/components/ui/input"; 7 + import { Button } from "@/components/ui/button"; 8 + 9 + function AtprotoLoginContent() { 10 + const [handle, setHandle] = useState(""); 11 + const [loading, setLoading] = useState(false); 12 + const [error, setError] = useState(""); 13 + const [registerLoading, setRegisterLoading] = useState(false); 14 + const searchParams = useSearchParams(); 15 + 16 + const startLogin = async () => { 17 + setLoading(true); 18 + setError(""); 19 + try { 20 + const res = await fetch("/api/atproto/login", { 21 + method: "POST", 22 + headers: { "Content-Type": "application/json" }, 23 + body: JSON.stringify({ handle }), 24 + }); 25 + 26 + const data = (await res.json()) as { authorizeUrl?: string; error?: string }; 27 + if (!res.ok || !data.authorizeUrl) { 28 + throw new Error(data.error || "Failed to start OAuth login"); 29 + } 30 + 31 + window.location.href = data.authorizeUrl; 32 + } catch (e) { 33 + setError(e instanceof Error ? e.message : "Unknown error"); 34 + } finally { 35 + setLoading(false); 36 + } 37 + }; 38 + 39 + const queryError = searchParams.get("reason") || searchParams.get("error") || ""; 40 + 41 + const startRegister = async () => { 42 + setRegisterLoading(true); 43 + setError(""); 44 + try { 45 + const res = await fetch("/api/atproto/register-url", { 46 + method: "POST", 47 + headers: { "Content-Type": "application/json" }, 48 + }); 49 + 50 + const data = (await res.json()) as { authorizeUrl?: string; error?: string }; 51 + if (!res.ok || !data.authorizeUrl) { 52 + throw new Error(data.error || "Failed to create registration OAuth URL"); 53 + } 54 + 55 + window.location.href = data.authorizeUrl; 56 + } catch (e) { 57 + setError(e instanceof Error ? e.message : "Unknown error"); 58 + } finally { 59 + setRegisterLoading(false); 60 + } 61 + }; 62 + 63 + 64 + return ( 65 + <div className="space-y-4"> 66 + <Card> 67 + <CardHeader className="text-center"> 68 + <CardTitle className="text-xl">Sign in with Atmosphere</CardTitle> 69 + <CardDescription>Enter your Atmosphere handle to continue with OAuth</CardDescription> 70 + </CardHeader> 71 + <CardContent className="space-y-4"> 72 + <Input 73 + value={handle} 74 + onChange={(e) => setHandle(e.target.value)} 75 + placeholder="alice.bsky.social" 76 + autoComplete="username" 77 + /> 78 + <Button className="w-full bg-[#0066ff] hover:bg-[#0052cc] text-white" onClick={startLogin} disabled={!handle || loading}> 79 + {loading ? "Redirecting..." : "Continue with Atmosphere OAuth"} 80 + </Button> 81 + {error || queryError ? <p className="text-sm text-red-500">{error || queryError}</p> : null} 82 + <Button 83 + variant="outline" 84 + className="w-full" 85 + type="button" 86 + onClick={startRegister} 87 + disabled={registerLoading} 88 + > 89 + {registerLoading ? "Preparing..." : "Create an Atmosphere account"} 90 + </Button> 91 + <p className="pt-1 text-center text-xs text-muted-foreground"> 92 + Not an Atmosphere user? Return to normal <a href="/sign-in" className="underline underline-offset-4 hover:text-primary">sign in</a> 93 + </p> 94 + </CardContent> 95 + </Card> 96 + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary"> 97 + By continuing, you agree to our <a href="/terms">Terms of Service</a> and <a href="/privacy">Privacy Policy</a>. 98 + </div> 99 + </div> 100 + ); 101 + } 102 + 103 + export function AtprotoLoginForm() { 104 + return ( 105 + <Suspense fallback={<div className="text-sm text-muted-foreground text-center">Loading...</div>}> 106 + <AtprotoLoginContent /> 107 + </Suspense> 108 + ); 109 + }
+1 -1
components/auth/auth-brand.tsx
··· 6 6 <span className="flex size-6 items-center justify-center rounded-md bg-primary/15 text-primary"> 7 7 <Image src="/icon.svg" alt="One Calendar" width={16} height={16} className="size-4" /> 8 8 </span> 9 - <span>Tech-Art</span> 9 + <span>One Calendar</span> 10 10 </a> 11 11 ) 12 12 }
+16 -4
components/auth/login-form.tsx
··· 5 5 import { 6 6 Card, 7 7 CardContent, 8 - CardDescription, 9 8 CardHeader, 10 9 CardTitle, 11 10 } from "@/components/ui/card"; ··· 116 115 <Card> 117 116 <CardHeader className="text-center"> 118 117 <CardTitle className="text-xl">Welcome back</CardTitle> 119 - <CardDescription> 120 - Login with your Microsoft, Google or GitHub account 121 - </CardDescription> 122 118 </CardHeader> 123 119 <CardContent> 124 120 <form onSubmit={handleEmailLogin}> ··· 152 148 <path d="M1 1h22v22H1z" fill="none"/> 153 149 </svg> 154 150 <span className="ml-2">Login with Google</span> 151 + </Button> 152 + <Button 153 + variant="outline" 154 + className="w-full" 155 + type="button" 156 + onClick={() => (window.location.href = "/at-oauth")} 157 + > 158 + <svg 159 + xmlns="http://www.w3.org/2000/svg" 160 + viewBox="0 0 640 560" 161 + className="h-5 w-auto shrink-0" 162 + aria-hidden="true" 163 + > 164 + <path fill="#0066ff" d="M133 47c74 56 152 169 197 260 45-91 123-204 197-260 53-40 140-70 140 28 0 20-11 170-18 194-22 85-103 106-175 93 124 22 156 91 87 161-131 134-188-34-203-75-2-6-3-12-3-18 0 6-1 12-3 18-15 41-72 209-203 75-69-70-37-139 87-161-72 13-153-8-175-93-7-24-18-174-18-194 0-98 87-68 140-28z"/> 165 + </svg> 166 + <span className="ml-2">Login with Atmosphere</span> 155 167 </Button> 156 168 <Button 157 169 variant="outline"
+17 -17
components/auth/sign-up-form.tsx
··· 5 5 import { 6 6 Card, 7 7 CardContent, 8 - CardDescription, 9 8 CardHeader, 10 9 CardTitle, 11 10 } from "@/components/ui/card"; ··· 37 36 ); 38 37 const turnstileRef = useRef<any>(null); 39 38 40 - console.log("Site Key:", process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "Missing"); 41 - 42 39 const handleTurnstileSuccess = async (token: string) => { 43 - console.log("Turnstile token received:", token.slice(0, 10) + "..."); 44 40 try { 45 41 const response = await fetch("/api/verify", { 46 42 method: "POST", ··· 49 45 }); 50 46 51 47 if (!response.ok) { 52 - console.error("API error:", response.status, response.statusText); 53 48 throw new Error(`HTTP error! Status: ${response.status}`); 54 49 } 55 50 ··· 58 53 try { 59 54 data = JSON.parse(text); 60 55 } catch (parseErr) { 61 - console.error("JSON parse error:", parseErr.message, "Response text:", text); 62 56 throw new Error("Invalid JSON response from server"); 63 57 } 64 58 65 - console.log("Verification API response:", JSON.stringify(data, null, 2)); 66 59 67 60 if (data.success) { 68 61 setIsCaptchaCompleted(true); ··· 72 65 setError(`CAPTCHA verification failed: ${data.details?.join(", ") || "Unknown error"}`); 73 66 if (turnstileRef.current) { 74 67 turnstileRef.current.reset(); 75 - console.log("Turnstile widget reset"); 76 68 } 77 69 } 78 70 } catch (err) { 79 - console.error("Error in handleTurnstileSuccess:", err.message); 80 71 setIsCaptchaCompleted(false); 81 72 setError("Error verifying CAPTCHA. Please try again."); 82 73 if (turnstileRef.current) { 83 74 turnstileRef.current.reset(); 84 - console.log("Turnstile widget reset"); 85 75 } 86 76 } 87 77 }; ··· 155 145 setIsCaptchaCompleted(false); 156 146 if (turnstileRef.current) { 157 147 turnstileRef.current.reset(); 158 - console.log("Turnstile widget reset due to submission error"); 159 148 } 160 149 } 161 150 } finally { ··· 231 220 <Card> 232 221 <CardHeader className="text-center"> 233 222 <CardTitle className="text-xl">Create your account</CardTitle> 234 - <CardDescription> 235 - Continue with Microsoft, Google, GitHub account 236 - </CardDescription> 237 223 </CardHeader> 238 224 <CardContent> 239 225 <form onSubmit={handleSubmit}> ··· 272 258 variant="outline" 273 259 className="w-full" 274 260 type="button" 261 + onClick={() => (window.location.href = "/at-oauth")} 262 + > 263 + <svg 264 + xmlns="http://www.w3.org/2000/svg" 265 + viewBox="0 0 640 560" 266 + className="h-5 w-auto shrink-0" 267 + aria-hidden="true" 268 + > 269 + <path fill="#0066ff" d="M133 47c74 56 152 169 197 260 45-91 123-204 197-260 53-40 140-70 140 28 0 20-11 170-18 194-22 85-103 106-175 93 124 22 156 91 87 161-131 134-188-34-203-75-2-6-3-12-3-18 0 6-1 12-3 18-15 41-72 209-203 75-69-70-37-139 87-161-72 13-153-8-175-93-7-24-18-174-18-194 0-98 87-68 140-28z"/> 270 + </svg> 271 + <span className="ml-2">Continue with Atmosphere</span> 272 + </Button> 273 + <Button 274 + variant="outline" 275 + className="w-full" 276 + type="button" 275 277 onClick={() => handleOAuthSignUp("oauth_github")} 276 278 > 277 279 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20"> ··· 359 361 }} 360 362 /> 361 363 </div> 362 - ) : ( 363 - <div className="text-sm text-yellow-500">CAPTCHA not configured: Missing site key</div> 364 - )} 364 + ) : null} 365 365 </div> 366 366 367 367 {error && <div className="text-sm text-red-500">{error}</div>}
+170
lib/atproto-auth.ts
··· 1 + import { createDecipheriv, createHash, createCipheriv, hkdfSync, randomBytes } from "node:crypto"; 2 + import type { DpopPublicJwk } from "@/lib/dpop"; 3 + import { cookies } from "next/headers"; 4 + 5 + export const ATPROTO_SESSION_COOKIE = "atproto_session"; 6 + 7 + export interface AtprotoSession { 8 + did: string; 9 + handle: string; 10 + pds: string; 11 + accessToken: string; 12 + refreshToken?: string; 13 + displayName?: string; 14 + avatar?: string; 15 + dpopPrivateKeyPem?: string; 16 + dpopPublicJwk?: DpopPublicJwk; 17 + } 18 + 19 + export interface KeyEntry { 20 + kid: string; 21 + secret: string; 22 + } 23 + 24 + function shouldUseSecureCookies() { 25 + return process.env.NODE_ENV === "production"; 26 + } 27 + 28 + function getRawLegacySecret() { 29 + return process.env.ATPROTO_SESSION_SECRET || process.env.NEXTAUTH_SECRET || ""; 30 + } 31 + 32 + export function getKeyEntries(): KeyEntry[] { 33 + const configured = process.env.ATPROTO_SESSION_KEYS?.trim(); 34 + if (configured) { 35 + const parsed = configured 36 + .split(",") 37 + .map((part) => part.trim()) 38 + .filter(Boolean) 39 + .map((pair) => { 40 + const idx = pair.indexOf(":"); 41 + if (idx <= 0 || idx === pair.length - 1) return null; 42 + return { 43 + kid: pair.slice(0, idx).trim(), 44 + secret: pair.slice(idx + 1).trim(), 45 + }; 46 + }) 47 + .filter((v): v is KeyEntry => !!v && !!v.kid && !!v.secret); 48 + 49 + if (parsed.length > 0) return parsed; 50 + } 51 + 52 + const current = process.env.ATPROTO_SESSION_SECRET_CURRENT?.trim(); 53 + const previous = process.env.ATPROTO_SESSION_SECRET_PREVIOUS?.trim(); 54 + const entries: KeyEntry[] = []; 55 + if (current) entries.push({ kid: "v1", secret: current }); 56 + if (previous) entries.push({ kid: "v0", secret: previous }); 57 + if (entries.length > 0) return entries; 58 + 59 + const legacy = getRawLegacySecret(); 60 + if (!legacy) return []; 61 + return [{ kid: "legacy", secret: legacy }]; 62 + } 63 + 64 + function deriveKey(secret: string, kid: string) { 65 + const salt = createHash("sha256").update(`atproto-cookie-salt:${kid}`, "utf8").digest(); 66 + const info = Buffer.from("one-calendar:atproto:cookie:v2", "utf8"); 67 + return Buffer.from(hkdfSync("sha256", Buffer.from(secret, "utf8"), salt, info, 32)); 68 + } 69 + 70 + function deriveLegacyKey(secret: string) { 71 + return createHash("sha256").update(secret, "utf8").digest(); 72 + } 73 + 74 + export function sealJsonPayload(payload: unknown, key: KeyEntry) { 75 + const iv = randomBytes(12); 76 + const derivedKey = deriveKey(key.secret, key.kid); 77 + const cipher = createCipheriv("aes-256-gcm", derivedKey, iv); 78 + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]); 79 + const tag = cipher.getAuthTag(); 80 + return `v2.${key.kid}.${iv.toString("base64url")}.${ciphertext.toString("base64url")}.${tag.toString("base64url")}`; 81 + } 82 + 83 + export function unsealJsonPayload<T>(raw: string): T | null { 84 + const v2parts = raw.split("."); 85 + if (v2parts.length === 5 && v2parts[0] === "v2") { 86 + const [, kid, ivRaw, ciphertextRaw, tagRaw] = v2parts; 87 + const keyEntry = getKeyEntries().find((entry) => entry.kid === kid); 88 + if (!keyEntry) return null; 89 + 90 + try { 91 + const iv = Buffer.from(ivRaw, "base64url"); 92 + const ciphertext = Buffer.from(ciphertextRaw, "base64url"); 93 + const tag = Buffer.from(tagRaw, "base64url"); 94 + const key = deriveKey(keyEntry.secret, keyEntry.kid); 95 + 96 + const decipher = createDecipheriv("aes-256-gcm", key, iv); 97 + decipher.setAuthTag(tag); 98 + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); 99 + return JSON.parse(plaintext) as T; 100 + } catch { 101 + return null; 102 + } 103 + } 104 + 105 + // Backward compatibility with previous v1 format: v1.iv.ciphertext.tag 106 + if (v2parts.length === 4 && v2parts[0] === "v1") { 107 + const keys = getKeyEntries(); 108 + for (const keyEntry of keys) { 109 + try { 110 + const iv = Buffer.from(v2parts[1], "base64url"); 111 + const ciphertext = Buffer.from(v2parts[2], "base64url"); 112 + const tag = Buffer.from(v2parts[3], "base64url"); 113 + const key = deriveLegacyKey(keyEntry.secret); 114 + 115 + const decipher = createDecipheriv("aes-256-gcm", key, iv); 116 + decipher.setAuthTag(tag); 117 + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); 118 + return JSON.parse(plaintext) as T; 119 + } catch { 120 + // try next key 121 + } 122 + } 123 + } 124 + 125 + return null; 126 + } 127 + 128 + function encodeSession(session: AtprotoSession) { 129 + const keys = getKeyEntries(); 130 + const activeKey = keys[0]; 131 + if (!activeKey) { 132 + throw new Error("Missing ATProto cookie key. Set ATPROTO_SESSION_KEYS or ATPROTO_SESSION_SECRET_CURRENT/ATPROTO_SESSION_SECRET"); 133 + } 134 + 135 + return sealJsonPayload(session, activeKey); 136 + } 137 + 138 + function decodeSession(raw: string): AtprotoSession | null { 139 + return unsealJsonPayload<AtprotoSession>(raw); 140 + } 141 + 142 + export async function getAtprotoSession(): Promise<AtprotoSession | null> { 143 + const store = await cookies(); 144 + const raw = store.get(ATPROTO_SESSION_COOKIE)?.value; 145 + if (!raw) return null; 146 + 147 + const decoded = decodeSession(raw); 148 + if (!decoded) { 149 + store.delete(ATPROTO_SESSION_COOKIE); 150 + } 151 + 152 + return decoded; 153 + } 154 + 155 + export async function setAtprotoSession(session: AtprotoSession) { 156 + const store = await cookies(); 157 + const value = encodeSession(session); 158 + store.set(ATPROTO_SESSION_COOKIE, value, { 159 + httpOnly: true, 160 + secure: shouldUseSecureCookies(), 161 + sameSite: "lax", 162 + path: "/", 163 + maxAge: 60 * 60 * 24 * 30, 164 + }); 165 + } 166 + 167 + export async function clearAtprotoSession() { 168 + const store = await cookies(); 169 + store.delete(ATPROTO_SESSION_COOKIE); 170 + }
+80
lib/atproto-oauth-txn.ts
··· 1 + import { createHash } from "node:crypto"; 2 + import type { DpopPublicJwk } from "@/lib/dpop"; 3 + import type { NextRequest, NextResponse } from "next/server"; 4 + import { getKeyEntries, sealJsonPayload, unsealJsonPayload } from "@/lib/atproto-auth"; 5 + 6 + export const ATPROTO_OAUTH_TXN_COOKIE = "atproto_oauth_txn"; 7 + const OAUTH_TXN_MAX_AGE_SECONDS = 600; 8 + 9 + export interface AtprotoOAuthTxn { 10 + jti: string; 11 + state: string; 12 + verifier: string; 13 + handle: string; 14 + pds: string; 15 + did: string; 16 + dpopPrivateKeyPem: string; 17 + dpopPublicJwk: DpopPublicJwk; 18 + issuedAt: number; 19 + } 20 + 21 + const consumedTxnCache = new Map<string, number>(); 22 + 23 + function cleanupConsumed(now: number) { 24 + for (const [key, exp] of consumedTxnCache.entries()) { 25 + if (exp <= now) consumedTxnCache.delete(key); 26 + } 27 + } 28 + 29 + function txnCacheKey(jti: string) { 30 + return createHash("sha256").update(jti, "utf8").digest("hex"); 31 + } 32 + 33 + export function setAtprotoOAuthTxnCookie(response: NextResponse, txn: AtprotoOAuthTxn, secure: boolean) { 34 + const keys = getKeyEntries(); 35 + const activeKey = keys[0]; 36 + if (!activeKey) { 37 + throw new Error("Missing ATProto cookie key for OAuth transaction protection"); 38 + } 39 + 40 + const value = sealJsonPayload(txn, activeKey); 41 + response.cookies.set(ATPROTO_OAUTH_TXN_COOKIE, value, { 42 + httpOnly: true, 43 + secure, 44 + sameSite: "lax", 45 + path: "/", 46 + maxAge: OAUTH_TXN_MAX_AGE_SECONDS, 47 + }); 48 + } 49 + 50 + export function getAtprotoOAuthTxnFromRequest(request: NextRequest) { 51 + const raw = request.cookies.get(ATPROTO_OAUTH_TXN_COOKIE)?.value; 52 + if (!raw) return null; 53 + 54 + const txn = unsealJsonPayload<AtprotoOAuthTxn>(raw); 55 + if (!txn) return null; 56 + 57 + const now = Math.floor(Date.now() / 1000); 58 + if (!txn.issuedAt || now - txn.issuedAt > OAUTH_TXN_MAX_AGE_SECONDS) { 59 + return null; 60 + } 61 + 62 + return txn; 63 + } 64 + 65 + export function consumeAtprotoOAuthTxn(txn: AtprotoOAuthTxn) { 66 + const now = Math.floor(Date.now() / 1000); 67 + cleanupConsumed(now); 68 + 69 + const key = txnCacheKey(txn.jti); 70 + if (consumedTxnCache.has(key)) { 71 + return false; 72 + } 73 + 74 + consumedTxnCache.set(key, now + OAUTH_TXN_MAX_AGE_SECONDS); 75 + return true; 76 + } 77 + 78 + export function clearAtprotoOAuthTxnCookie(response: NextResponse) { 79 + response.cookies.delete(ATPROTO_OAUTH_TXN_COOKIE); 80 + }
+270
lib/atproto.ts
··· 1 + import { randomBytes, createHash } from "crypto"; 2 + import { createDpopProof, type DpopPublicJwk } from "@/lib/dpop"; 3 + 4 + export interface DidDocService { 5 + id?: string; 6 + type?: string; 7 + serviceEndpoint?: string; 8 + } 9 + 10 + export interface DidDoc { 11 + service?: DidDocService[]; 12 + } 13 + 14 + export async function resolveHandle(handle: string): Promise<{ did: string; pds: string }> { 15 + const normalized = handle.replace(/^@/, "").trim().toLowerCase(); 16 + const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(normalized)}`, { cache: "no-store" }); 17 + if (!didRes.ok) { 18 + throw new Error("Failed to resolve handle"); 19 + } 20 + 21 + const didData = (await didRes.json()) as { did?: string }; 22 + if (!didData.did) { 23 + throw new Error("No DID found for handle"); 24 + } 25 + 26 + const didDocRes = await fetch(`https://plc.directory/${encodeURIComponent(didData.did)}`, { cache: "no-store" }); 27 + if (!didDocRes.ok) { 28 + throw new Error("Failed to resolve DID document"); 29 + } 30 + 31 + const didDoc = (await didDocRes.json()) as DidDoc; 32 + const pds = didDoc.service?.find((s) => s.type === "AtprotoPersonalDataServer")?.serviceEndpoint; 33 + if (!pds) { 34 + throw new Error("Could not find PDS endpoint"); 35 + } 36 + 37 + return { did: didData.did, pds }; 38 + } 39 + 40 + export function createPkcePair() { 41 + const verifier = randomBytes(32).toString("base64url"); 42 + const challenge = createHash("sha256").update(verifier).digest("base64url"); 43 + return { verifier, challenge }; 44 + } 45 + 46 + async function createAuthHeaders(params: { 47 + url: string; 48 + method: string; 49 + accessToken?: string; 50 + dpopPrivateKeyPem?: string; 51 + dpopPublicJwk?: DpopPublicJwk; 52 + contentType?: string; 53 + nonce?: string; 54 + }) { 55 + const headers: Record<string, string> = {}; 56 + if (params.contentType) headers["Content-Type"] = params.contentType; 57 + 58 + if (!params.accessToken) return headers; 59 + 60 + if (params.dpopPrivateKeyPem && params.dpopPublicJwk) { 61 + const dpopProof = createDpopProof({ 62 + htu: params.url, 63 + htm: params.method, 64 + privateKeyPem: params.dpopPrivateKeyPem, 65 + publicJwk: params.dpopPublicJwk, 66 + accessToken: params.accessToken, 67 + nonce: params.nonce, 68 + }); 69 + 70 + headers.Authorization = `DPoP ${params.accessToken}`; 71 + headers.DPoP = dpopProof; 72 + return headers; 73 + } 74 + 75 + headers.Authorization = `Bearer ${params.accessToken}`; 76 + return headers; 77 + } 78 + 79 + async function fetchWithDpopNonceRetry(params: { 80 + url: string; 81 + method: "GET" | "POST"; 82 + accessToken?: string; 83 + dpopPrivateKeyPem?: string; 84 + dpopPublicJwk?: DpopPublicJwk; 85 + contentType?: string; 86 + body?: string; 87 + }) { 88 + const doFetch = async (nonce?: string) => 89 + fetch(params.url, { 90 + method: params.method, 91 + headers: await createAuthHeaders({ 92 + url: params.url, 93 + method: params.method, 94 + accessToken: params.accessToken, 95 + dpopPrivateKeyPem: params.dpopPrivateKeyPem, 96 + dpopPublicJwk: params.dpopPublicJwk, 97 + contentType: params.contentType, 98 + nonce, 99 + }), 100 + body: params.body, 101 + cache: "no-store", 102 + }); 103 + 104 + let res = await doFetch(); 105 + if (res.ok) return res; 106 + 107 + let nonce = res.headers.get("DPoP-Nonce") || res.headers.get("dpop-nonce"); 108 + const hasDpop = !!params.accessToken && !!params.dpopPrivateKeyPem && !!params.dpopPublicJwk; 109 + 110 + if (hasDpop && !nonce) { 111 + const body = await res.clone().text(); 112 + try { 113 + const parsed = JSON.parse(body) as { dpop_nonce?: string; nonce?: string }; 114 + nonce = parsed.dpop_nonce || parsed.nonce; 115 + } catch { 116 + nonce = nonce || undefined; 117 + } 118 + } 119 + 120 + if (hasDpop && nonce) { 121 + res = await doFetch(nonce); 122 + } 123 + 124 + return res; 125 + } 126 + 127 + export async function getProfile(pds: string, actor: string, accessToken: string, dpop?: { privateKeyPem?: string; publicJwk?: DpopPublicJwk }) { 128 + const url = `${pds.replace(/\/$/, "")}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`; 129 + const res = await fetchWithDpopNonceRetry({ 130 + url, 131 + method: "GET", 132 + accessToken, 133 + dpopPrivateKeyPem: dpop?.privateKeyPem, 134 + dpopPublicJwk: dpop?.publicJwk, 135 + }); 136 + if (!res.ok) return null; 137 + return res.json() as Promise<{ displayName?: string; avatar?: string; handle?: string }>; 138 + } 139 + 140 + 141 + export async function getActorProfileRecord(params: { 142 + pds: string; 143 + repo: string; 144 + accessToken: string; 145 + dpopPrivateKeyPem?: string; 146 + dpopPublicJwk?: DpopPublicJwk; 147 + }) { 148 + const record = await getRecord({ 149 + pds: params.pds, 150 + repo: params.repo, 151 + collection: "app.bsky.actor.profile", 152 + rkey: "self", 153 + accessToken: params.accessToken, 154 + dpopPrivateKeyPem: params.dpopPrivateKeyPem, 155 + dpopPublicJwk: params.dpopPublicJwk, 156 + }); 157 + 158 + return record.value as 159 + | { 160 + displayName?: string; 161 + avatar?: { ref?: { $link?: string } }; 162 + } 163 + | undefined; 164 + } 165 + 166 + export function profileAvatarBlobUrl(params: { pds: string; did: string; cid?: string }) { 167 + if (!params.cid) return undefined; 168 + return `${params.pds.replace(/\/$/, "")}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(params.did)}&cid=${encodeURIComponent(params.cid)}`; 169 + } 170 + 171 + export async function putRecord(params: { 172 + pds: string; 173 + repo: string; 174 + collection: string; 175 + rkey: string; 176 + record: Record<string, unknown>; 177 + accessToken: string; 178 + dpopPrivateKeyPem?: string; 179 + dpopPublicJwk?: DpopPublicJwk; 180 + }) { 181 + const { pds, accessToken, dpopPrivateKeyPem, dpopPublicJwk, ...payload } = params; 182 + const url = `${pds.replace(/\/$/, "")}/xrpc/com.atproto.repo.putRecord`; 183 + const res = await fetchWithDpopNonceRetry({ 184 + url, 185 + method: "POST", 186 + accessToken, 187 + dpopPrivateKeyPem, 188 + dpopPublicJwk, 189 + contentType: "application/json", 190 + body: JSON.stringify(payload), 191 + }); 192 + if (!res.ok) { 193 + throw new Error(`putRecord failed: ${await res.text()}`); 194 + } 195 + return res.json(); 196 + } 197 + 198 + export async function getRecord(params: { 199 + pds: string; 200 + repo: string; 201 + collection: string; 202 + rkey: string; 203 + accessToken?: string; 204 + dpopPrivateKeyPem?: string; 205 + dpopPublicJwk?: DpopPublicJwk; 206 + }) { 207 + const { pds, repo, collection, rkey, accessToken, dpopPrivateKeyPem, dpopPublicJwk } = params; 208 + const url = `${pds.replace(/\/$/, "")}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`; 209 + const res = await fetchWithDpopNonceRetry({ 210 + url, 211 + method: "GET", 212 + accessToken, 213 + dpopPrivateKeyPem, 214 + dpopPublicJwk, 215 + }); 216 + if (!res.ok) { 217 + throw new Error(`getRecord failed: ${await res.text()}`); 218 + } 219 + return res.json() as Promise<{ value?: Record<string, unknown> }>; 220 + } 221 + 222 + export async function listRecords(params: { 223 + pds: string; 224 + repo: string; 225 + collection: string; 226 + accessToken: string; 227 + dpopPrivateKeyPem?: string; 228 + dpopPublicJwk?: DpopPublicJwk; 229 + }) { 230 + const { pds, repo, collection, accessToken, dpopPrivateKeyPem, dpopPublicJwk } = params; 231 + const url = `${pds.replace(/\/$/, "")}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&limit=100`; 232 + const res = await fetchWithDpopNonceRetry({ 233 + url, 234 + method: "GET", 235 + accessToken, 236 + dpopPrivateKeyPem, 237 + dpopPublicJwk, 238 + }); 239 + 240 + if (!res.ok) { 241 + throw new Error(`listRecords failed: ${await res.text()}`); 242 + } 243 + 244 + return res.json() as Promise<{ records?: Array<{ uri: string; value?: Record<string, unknown> }> }>; 245 + } 246 + 247 + export async function deleteRecord(params: { 248 + pds: string; 249 + repo: string; 250 + collection: string; 251 + rkey: string; 252 + accessToken: string; 253 + dpopPrivateKeyPem?: string; 254 + dpopPublicJwk?: DpopPublicJwk; 255 + }) { 256 + const { pds, accessToken, dpopPrivateKeyPem, dpopPublicJwk, ...payload } = params; 257 + const url = `${pds.replace(/\/$/, "")}/xrpc/com.atproto.repo.deleteRecord`; 258 + const res = await fetchWithDpopNonceRetry({ 259 + url, 260 + method: "POST", 261 + accessToken, 262 + dpopPrivateKeyPem, 263 + dpopPublicJwk, 264 + contentType: "application/json", 265 + body: JSON.stringify(payload), 266 + }); 267 + if (!res.ok) { 268 + throw new Error(`deleteRecord failed: ${await res.text()}`); 269 + } 270 + }
+85
lib/dpop.ts
··· 1 + import { createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign } from "crypto"; 2 + 3 + export interface DpopPublicJwk { 4 + kty: string; 5 + crv: string; 6 + x: string; 7 + y: string; 8 + } 9 + 10 + function toBase64Url(input: Buffer | string) { 11 + return Buffer.from(input).toString("base64url"); 12 + } 13 + 14 + function jwkThumbprint(publicJwk: DpopPublicJwk) { 15 + const canonical = JSON.stringify({ 16 + crv: publicJwk.crv, 17 + kty: publicJwk.kty, 18 + x: publicJwk.x, 19 + y: publicJwk.y, 20 + }); 21 + 22 + return createHash("sha256").update(canonical, "utf8").digest("base64url"); 23 + } 24 + 25 + export function generateDpopKeyMaterial() { 26 + const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); 27 + const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; 28 + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); 29 + 30 + if (!publicJwk?.kty || !publicJwk?.crv || !publicJwk?.x || !publicJwk?.y) { 31 + throw new Error("Failed to generate DPoP key material"); 32 + } 33 + 34 + return { 35 + publicJwk, 36 + privateKeyPem, 37 + jkt: jwkThumbprint(publicJwk), 38 + }; 39 + } 40 + 41 + export function createDpopProof(params: { 42 + htu: string; 43 + htm: string; 44 + privateKeyPem: string; 45 + publicJwk: DpopPublicJwk; 46 + accessToken?: string; 47 + nonce?: string; 48 + }) { 49 + const header = { 50 + typ: "dpop+jwt", 51 + alg: "ES256", 52 + jwk: { 53 + kty: params.publicJwk.kty, 54 + crv: params.publicJwk.crv, 55 + x: params.publicJwk.x, 56 + y: params.publicJwk.y, 57 + }, 58 + }; 59 + 60 + const payload: Record<string, string | number> = { 61 + jti: randomUUID(), 62 + iat: Math.floor(Date.now() / 1000), 63 + htm: params.htm.toUpperCase(), 64 + htu: params.htu, 65 + }; 66 + 67 + if (params.accessToken) { 68 + payload.ath = createHash("sha256").update(params.accessToken, "utf8").digest("base64url"); 69 + } 70 + 71 + if (params.nonce) { 72 + payload.nonce = params.nonce; 73 + } 74 + 75 + const encodedHeader = toBase64Url(JSON.stringify(header)); 76 + const encodedPayload = toBase64Url(JSON.stringify(payload)); 77 + const signingInput = `${encodedHeader}.${encodedPayload}`; 78 + 79 + const signature = sign("sha256", Buffer.from(signingInput, "utf8"), { 80 + key: createPrivateKey(params.privateKeyPem), 81 + dsaEncoding: "ieee-p1363", 82 + }); 83 + 84 + return `${signingInput}.${toBase64Url(signature)}`; 85 + }
+19
lib/gen-oauth-metadata.mjs
··· 1 + import { writeFileSync } from "node:fs"; 2 + 3 + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://calendar.xyehr.cn"; 4 + const normalizedBase = baseUrl.replace(/\/$/, ""); 5 + 6 + const metadata = { 7 + client_id: `${normalizedBase}/oauth-client-metadata.json`, 8 + application_type: "web", 9 + grant_types: ["authorization_code", "refresh_token"], 10 + response_types: ["code"], 11 + redirect_uris: [`${normalizedBase}/api/atproto/callback`], 12 + token_endpoint_auth_method: "none", 13 + scope: "atproto transition:generic", 14 + dpop_bound_access_tokens: true, 15 + }; 16 + 17 + writeFileSync("public/oauth-client-metadata.json", `${JSON.stringify(metadata, null, 2)} 18 + `, "utf8"); 19 + console.log("Generated public/oauth-client-metadata.json");
+5 -4
package.json
··· 1 1 { 2 2 "name": "one-calendar", 3 - "version": "2.1.3", 3 + "version": "2.2.0", 4 4 "private": true, 5 5 "packageManager": "bun@1.3.6", 6 6 "scripts": { 7 - "dev": "bun run generate:locales && next dev", 8 - "build": "bun run generate:locales && next build", 7 + "dev": "bun run generate:locales && bun run generate:oauth-metadata && next dev", 8 + "build": "bun run generate:locales && bun run generate:oauth-metadata && next build", 9 9 "start": "next start", 10 10 "db": "docker-compose up -d", 11 11 "db:down": "docker-compose down", 12 12 "db:stop": "docker-compose stop", 13 13 "db:clean": "docker-compose down -v", 14 - "generate:locales": "bun lib/gen-locales.mjs" 14 + "generate:locales": "bun lib/gen-locales.mjs", 15 + "generate:oauth-metadata": "bun lib/gen-oauth-metadata.mjs" 15 16 }, 16 17 "dependencies": { 17 18 "@hookform/resolvers": "5.2.2",
+5 -1
proxy.ts
··· 9 9 "/reset-password", 10 10 "/api/blob", 11 11 "/api/share", 12 - "/api/verify" 12 + "/api/verify", 13 + "/at-oauth", 14 + "/api/atproto/(.*)", 15 + "/oauth-client-metadata.json", 16 + "/api/share/public" 13 17 ], 14 18 }) 15 19
+17
public/oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://calendar.xyehr.cn/oauth-client-metadata.json", 3 + "application_type": "web", 4 + "grant_types": [ 5 + "authorization_code", 6 + "refresh_token" 7 + ], 8 + "response_types": [ 9 + "code" 10 + ], 11 + "redirect_uris": [ 12 + "https://calendar.xyehr.cn/api/atproto/callback" 13 + ], 14 + "token_endpoint_auth_method": "none", 15 + "scope": "atproto transition:generic", 16 + "dpop_bound_access_tokens": true 17 + }