chore: mvp of comments
f4cbdd79
8 file(s) · +933 −1
| 1 | + | import { useState, useEffect } from "react"; |
|
| 2 | + | ||
| 3 | + | const API_URL = import.meta.env.PUBLIC_API_URL || "https://api.stevedylan.dev"; |
|
| 4 | + | ||
| 5 | + | interface GuestAuthState { |
|
| 6 | + | authenticated: boolean; |
|
| 7 | + | did?: string; |
|
| 8 | + | handle?: string; |
|
| 9 | + | isGuest?: boolean; |
|
| 10 | + | loading: boolean; |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | interface GuestReplyProps { |
|
| 14 | + | atUri: string; |
|
| 15 | + | postTitle: string; |
|
| 16 | + | onReplyPosted?: () => void; |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | export function GuestReply({ |
|
| 20 | + | atUri, |
|
| 21 | + | postTitle, |
|
| 22 | + | onReplyPosted, |
|
| 23 | + | }: GuestReplyProps) { |
|
| 24 | + | const [authState, setAuthState] = useState<GuestAuthState>({ |
|
| 25 | + | authenticated: false, |
|
| 26 | + | loading: true, |
|
| 27 | + | }); |
|
| 28 | + | const [replyContent, setReplyContent] = useState(""); |
|
| 29 | + | const [isSubmitting, setIsSubmitting] = useState(false); |
|
| 30 | + | const [error, setError] = useState<string | null>(null); |
|
| 31 | + | const [success, setSuccess] = useState(false); |
|
| 32 | + | ||
| 33 | + | useEffect(() => { |
|
| 34 | + | checkAuthStatus(); |
|
| 35 | + | }, []); |
|
| 36 | + | ||
| 37 | + | const checkAuthStatus = async () => { |
|
| 38 | + | try { |
|
| 39 | + | const response = await fetch(`${API_URL}/guest-auth/status`, { |
|
| 40 | + | credentials: "include", |
|
| 41 | + | }); |
|
| 42 | + | const data = await response.json(); |
|
| 43 | + | setAuthState({ |
|
| 44 | + | authenticated: data.authenticated, |
|
| 45 | + | did: data.did, |
|
| 46 | + | handle: data.handle, |
|
| 47 | + | isGuest: data.isGuest, |
|
| 48 | + | loading: false, |
|
| 49 | + | }); |
|
| 50 | + | } catch (error) { |
|
| 51 | + | console.error("Failed to check auth status:", error); |
|
| 52 | + | setAuthState({ authenticated: false, loading: false }); |
|
| 53 | + | } |
|
| 54 | + | }; |
|
| 55 | + | ||
| 56 | + | const handleLogin = () => { |
|
| 57 | + | // Pass current URL as returnTo parameter |
|
| 58 | + | const currentPath = window.location.pathname; |
|
| 59 | + | window.location.href = `${API_URL}/guest-auth/login?returnTo=${encodeURIComponent(currentPath)}`; |
|
| 60 | + | }; |
|
| 61 | + | ||
| 62 | + | const handleLogout = async () => { |
|
| 63 | + | try { |
|
| 64 | + | await fetch(`${API_URL}/guest-auth/logout`, { |
|
| 65 | + | method: "POST", |
|
| 66 | + | credentials: "include", |
|
| 67 | + | }); |
|
| 68 | + | setAuthState({ authenticated: false, loading: false }); |
|
| 69 | + | setReplyContent(""); |
|
| 70 | + | } catch (error) { |
|
| 71 | + | console.error("Logout failed:", error); |
|
| 72 | + | } |
|
| 73 | + | }; |
|
| 74 | + | ||
| 75 | + | const handleReply = async (e: React.FormEvent) => { |
|
| 76 | + | e.preventDefault(); |
|
| 77 | + | ||
| 78 | + | if (!replyContent.trim()) { |
|
| 79 | + | setError("Reply content is required"); |
|
| 80 | + | return; |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | setIsSubmitting(true); |
|
| 84 | + | setError(null); |
|
| 85 | + | setSuccess(false); |
|
| 86 | + | ||
| 87 | + | try { |
|
| 88 | + | const response = await fetch(`${API_URL}/now/reply`, { |
|
| 89 | + | method: "POST", |
|
| 90 | + | credentials: "include", |
|
| 91 | + | headers: { |
|
| 92 | + | "Content-Type": "application/json", |
|
| 93 | + | }, |
|
| 94 | + | body: JSON.stringify({ |
|
| 95 | + | parentUri: atUri, |
|
| 96 | + | content: replyContent.trim(), |
|
| 97 | + | }), |
|
| 98 | + | }); |
|
| 99 | + | ||
| 100 | + | const data = await response.json(); |
|
| 101 | + | ||
| 102 | + | if (!response.ok) { |
|
| 103 | + | throw new Error(data.error || "Failed to post reply"); |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | setReplyContent(""); |
|
| 107 | + | setSuccess(true); |
|
| 108 | + | setTimeout(() => setSuccess(false), 5000); |
|
| 109 | + | ||
| 110 | + | // Notify parent to refresh replies list |
|
| 111 | + | onReplyPosted?.(); |
|
| 112 | + | } catch (err) { |
|
| 113 | + | setError(err instanceof Error ? err.message : "Failed to post reply"); |
|
| 114 | + | } finally { |
|
| 115 | + | setIsSubmitting(false); |
|
| 116 | + | } |
|
| 117 | + | }; |
|
| 118 | + | ||
| 119 | + | const emailSubject = encodeURIComponent(`Re: ${postTitle}`); |
|
| 120 | + | const mailtoLink = `mailto:contact@stevedylan.dev?subject=${emailSubject}`; |
|
| 121 | + | ||
| 122 | + | if (authState.loading) { |
|
| 123 | + | return ( |
|
| 124 | + | <div className="mt-8 p-6 border border-gray-700 rounded-lg"> |
|
| 125 | + | <p className="text-sm text-gray-400">Loading...</p> |
|
| 126 | + | </div> |
|
| 127 | + | ); |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | return ( |
|
| 131 | + | <div className="mt-8 space-y-4"> |
|
| 132 | + | <h2 className="text-xl font-bold">Reply</h2> |
|
| 133 | + | ||
| 134 | + | {!authState.authenticated ? ( |
|
| 135 | + | <div className="space-y-4"> |
|
| 136 | + | <p className="text-sm text-gray-400"> |
|
| 137 | + | Sign in with your ATProto account to reply, or send an email. |
|
| 138 | + | </p> |
|
| 139 | + | <div className="flex gap-3 flex-wrap"> |
|
| 140 | + | <button |
|
| 141 | + | onClick={handleLogin} |
|
| 142 | + | className="px-4 py-2 border border-white hover:bg-white hover:text-black transition-colors text-sm" |
|
| 143 | + | > |
|
| 144 | + | Sign in with ATProto |
|
| 145 | + | </button> |
|
| 146 | + | <a |
|
| 147 | + | href={mailtoLink} |
|
| 148 | + | className="px-4 py-2 border border-white hover:bg-white hover:text-black transition-colors text-sm inline-block" |
|
| 149 | + | > |
|
| 150 | + | Reply via Email |
|
| 151 | + | </a> |
|
| 152 | + | </div> |
|
| 153 | + | </div> |
|
| 154 | + | ) : ( |
|
| 155 | + | <div className="space-y-4"> |
|
| 156 | + | <div className="flex items-center justify-between"> |
|
| 157 | + | <p className="text-sm text-gray-400"> |
|
| 158 | + | Signed in as {authState.handle || authState.did} |
|
| 159 | + | </p> |
|
| 160 | + | <button |
|
| 161 | + | onClick={handleLogout} |
|
| 162 | + | className="text-xs text-gray-500 hover:text-gray-300 transition-colors" |
|
| 163 | + | > |
|
| 164 | + | Sign out |
|
| 165 | + | </button> |
|
| 166 | + | </div> |
|
| 167 | + | ||
| 168 | + | <form onSubmit={handleReply} className="space-y-3"> |
|
| 169 | + | <textarea |
|
| 170 | + | value={replyContent} |
|
| 171 | + | onChange={(e) => setReplyContent(e.target.value)} |
|
| 172 | + | placeholder="Write your reply..." |
|
| 173 | + | rows={4} |
|
| 174 | + | className="w-full bg-transparent p-3 border border-white text-white resize-none" |
|
| 175 | + | disabled={isSubmitting} |
|
| 176 | + | /> |
|
| 177 | + | ||
| 178 | + | <div className="flex items-center justify-between"> |
|
| 179 | + | <div className="flex items-center gap-3"> |
|
| 180 | + | {error && <span className="text-sm text-red-500">{error}</span>} |
|
| 181 | + | {success && ( |
|
| 182 | + | <span className="text-sm text-green-500"> |
|
| 183 | + | Reply posted successfully! |
|
| 184 | + | </span> |
|
| 185 | + | )} |
|
| 186 | + | </div> |
|
| 187 | + | ||
| 188 | + | <div className="flex gap-3"> |
|
| 189 | + | <a |
|
| 190 | + | href={mailtoLink} |
|
| 191 | + | className="text-sm text-gray-500 hover:text-gray-300 transition-colors" |
|
| 192 | + | > |
|
| 193 | + | or email |
|
| 194 | + | </a> |
|
| 195 | + | <button |
|
| 196 | + | type="submit" |
|
| 197 | + | disabled={isSubmitting || !replyContent.trim()} |
|
| 198 | + | className="px-4 py-2 border border-white hover:bg-white hover:text-black disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm" |
|
| 199 | + | > |
|
| 200 | + | {isSubmitting ? "Posting..." : "Post Reply"} |
|
| 201 | + | </button> |
|
| 202 | + | </div> |
|
| 203 | + | </div> |
|
| 204 | + | </form> |
|
| 205 | + | </div> |
|
| 206 | + | )} |
|
| 207 | + | </div> |
|
| 208 | + | ); |
|
| 209 | + | } |
| 1 | + | import { useState } from "react"; |
|
| 2 | + | import { ReplyList } from "./ReplyList"; |
|
| 3 | + | import { GuestReply } from "./GuestReply"; |
|
| 4 | + | ||
| 5 | + | interface ReplyContainerProps { |
|
| 6 | + | atUri: string; |
|
| 7 | + | postTitle: string; |
|
| 8 | + | } |
|
| 9 | + | ||
| 10 | + | export function ReplyContainer({ atUri, postTitle }: ReplyContainerProps) { |
|
| 11 | + | const [refreshKey, setRefreshKey] = useState(0); |
|
| 12 | + | ||
| 13 | + | const handleReplyPosted = () => { |
|
| 14 | + | // Increment key to force ReplyList to re-fetch |
|
| 15 | + | setRefreshKey((prev) => prev + 1); |
|
| 16 | + | }; |
|
| 17 | + | ||
| 18 | + | return ( |
|
| 19 | + | <> |
|
| 20 | + | <ReplyList key={refreshKey} atUri={atUri} /> |
|
| 21 | + | <GuestReply |
|
| 22 | + | atUri={atUri} |
|
| 23 | + | postTitle={postTitle} |
|
| 24 | + | onReplyPosted={handleReplyPosted} |
|
| 25 | + | /> |
|
| 26 | + | </> |
|
| 27 | + | ); |
|
| 28 | + | } |
| 1 | + | import { useState, useEffect } from "react"; |
|
| 2 | + | ||
| 3 | + | const API_URL = import.meta.env.PUBLIC_API_URL || "https://api.stevedylan.dev"; |
|
| 4 | + | ||
| 5 | + | interface Author { |
|
| 6 | + | did: string; |
|
| 7 | + | handle: string; |
|
| 8 | + | displayName?: string; |
|
| 9 | + | avatar?: string; |
|
| 10 | + | } |
|
| 11 | + | ||
| 12 | + | interface Reply { |
|
| 13 | + | uri: string; |
|
| 14 | + | cid: string; |
|
| 15 | + | author: Author; |
|
| 16 | + | record: { |
|
| 17 | + | text: string; |
|
| 18 | + | createdAt: string; |
|
| 19 | + | }; |
|
| 20 | + | indexedAt: string; |
|
| 21 | + | replyCount: number; |
|
| 22 | + | likeCount: number; |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | interface ReplyListProps { |
|
| 26 | + | atUri: string; |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | export function ReplyList({ atUri }: ReplyListProps) { |
|
| 30 | + | const [replies, setReplies] = useState<Reply[]>([]); |
|
| 31 | + | const [loading, setLoading] = useState(true); |
|
| 32 | + | const [error, setError] = useState<string | null>(null); |
|
| 33 | + | ||
| 34 | + | useEffect(() => { |
|
| 35 | + | fetchReplies(); |
|
| 36 | + | }, [atUri]); |
|
| 37 | + | ||
| 38 | + | const fetchReplies = async () => { |
|
| 39 | + | try { |
|
| 40 | + | setLoading(true); |
|
| 41 | + | setError(null); |
|
| 42 | + | ||
| 43 | + | const encodedUri = encodeURIComponent(atUri); |
|
| 44 | + | const response = await fetch(`${API_URL}/now/replies/${encodedUri}`); |
|
| 45 | + | ||
| 46 | + | if (!response.ok) { |
|
| 47 | + | throw new Error("Failed to fetch replies"); |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | const data = await response.json(); |
|
| 51 | + | setReplies(data.replies || []); |
|
| 52 | + | } catch (err) { |
|
| 53 | + | console.error("Error fetching replies:", err); |
|
| 54 | + | setError(err instanceof Error ? err.message : "Failed to load replies"); |
|
| 55 | + | } finally { |
|
| 56 | + | setLoading(false); |
|
| 57 | + | } |
|
| 58 | + | }; |
|
| 59 | + | ||
| 60 | + | const formatDate = (dateString: string) => { |
|
| 61 | + | const date = new Date(dateString); |
|
| 62 | + | const now = new Date(); |
|
| 63 | + | const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); |
|
| 64 | + | ||
| 65 | + | if (diffInSeconds < 60) { |
|
| 66 | + | return `${diffInSeconds}s ago`; |
|
| 67 | + | } else if (diffInSeconds < 3600) { |
|
| 68 | + | return `${Math.floor(diffInSeconds / 60)}m ago`; |
|
| 69 | + | } else if (diffInSeconds < 86400) { |
|
| 70 | + | return `${Math.floor(diffInSeconds / 3600)}h ago`; |
|
| 71 | + | } else if (diffInSeconds < 604800) { |
|
| 72 | + | return `${Math.floor(diffInSeconds / 86400)}d ago`; |
|
| 73 | + | } else { |
|
| 74 | + | return date.toLocaleDateString(); |
|
| 75 | + | } |
|
| 76 | + | }; |
|
| 77 | + | ||
| 78 | + | if (loading) { |
|
| 79 | + | return ( |
|
| 80 | + | <div className="mt-8"> |
|
| 81 | + | <h3 className="text-lg font-bold mb-4">Replies</h3> |
|
| 82 | + | <p className="text-sm text-gray-400">Loading replies...</p> |
|
| 83 | + | </div> |
|
| 84 | + | ); |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | if (error) { |
|
| 88 | + | return ( |
|
| 89 | + | <div className="mt-8"> |
|
| 90 | + | <h3 className="text-lg font-bold mb-4">Replies</h3> |
|
| 91 | + | <p className="text-sm text-red-500">{error}</p> |
|
| 92 | + | </div> |
|
| 93 | + | ); |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | if (replies.length === 0) { |
|
| 97 | + | return ( |
|
| 98 | + | <div className="mt-8"> |
|
| 99 | + | <h3 className="text-lg font-bold mb-4">Replies</h3> |
|
| 100 | + | <p className="text-sm text-gray-400"> |
|
| 101 | + | No replies yet. Be the first to reply! |
|
| 102 | + | </p> |
|
| 103 | + | </div> |
|
| 104 | + | ); |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | return ( |
|
| 108 | + | <div className="mt-8"> |
|
| 109 | + | <h3 className="text-lg font-bold mb-4">Replies ({replies.length})</h3> |
|
| 110 | + | <div className="space-y-4"> |
|
| 111 | + | {replies.map((reply) => ( |
|
| 112 | + | <div |
|
| 113 | + | key={reply.uri} |
|
| 114 | + | className="border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors" |
|
| 115 | + | > |
|
| 116 | + | <div className="flex items-start gap-3"> |
|
| 117 | + | {reply.author.avatar ? ( |
|
| 118 | + | <img |
|
| 119 | + | src={reply.author.avatar} |
|
| 120 | + | alt={reply.author.handle} |
|
| 121 | + | className="w-10 h-10 rounded-full" |
|
| 122 | + | /> |
|
| 123 | + | ) : ( |
|
| 124 | + | <div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center"> |
|
| 125 | + | <span className="text-gray-400 text-sm"> |
|
| 126 | + | {reply.author.handle.charAt(0).toUpperCase()} |
|
| 127 | + | </span> |
|
| 128 | + | </div> |
|
| 129 | + | )} |
|
| 130 | + | ||
| 131 | + | <div className="flex-1 min-w-0"> |
|
| 132 | + | <div className="flex items-center gap-2 flex-wrap"> |
|
| 133 | + | <span className="font-semibold text-sm"> |
|
| 134 | + | {reply.author.displayName || reply.author.handle} |
|
| 135 | + | </span> |
|
| 136 | + | <a |
|
| 137 | + | href={`https://bsky.app/profile/${reply.author.handle}`} |
|
| 138 | + | target="_blank" |
|
| 139 | + | rel="noopener noreferrer" |
|
| 140 | + | className="text-xs text-gray-400 hover:text-gray-300" |
|
| 141 | + | > |
|
| 142 | + | @{reply.author.handle} |
|
| 143 | + | </a> |
|
| 144 | + | <span className="text-xs text-gray-500"> |
|
| 145 | + | {formatDate(reply.record.createdAt)} |
|
| 146 | + | </span> |
|
| 147 | + | </div> |
|
| 148 | + | ||
| 149 | + | <p className="mt-2 text-sm whitespace-pre-wrap break-words"> |
|
| 150 | + | {reply.record.text} |
|
| 151 | + | </p> |
|
| 152 | + | ||
| 153 | + | <div className="mt-3 flex items-center gap-4 text-xs text-gray-500"> |
|
| 154 | + | {reply.replyCount > 0 && ( |
|
| 155 | + | <span>{reply.replyCount} replies</span> |
|
| 156 | + | )} |
|
| 157 | + | {reply.likeCount > 0 && <span>{reply.likeCount} likes</span>} |
|
| 158 | + | <a |
|
| 159 | + | href={`https://bsky.app/profile/${reply.author.handle}/post/${reply.uri.split("/").pop()}`} |
|
| 160 | + | target="_blank" |
|
| 161 | + | rel="noopener noreferrer" |
|
| 162 | + | className="hover:text-gray-300" |
|
| 163 | + | > |
|
| 164 | + | View on Bluesky |
|
| 165 | + | </a> |
|
| 166 | + | </div> |
|
| 167 | + | </div> |
|
| 168 | + | </div> |
|
| 169 | + | </div> |
|
| 170 | + | ))} |
|
| 171 | + | </div> |
|
| 172 | + | </div> |
|
| 173 | + | ); |
|
| 174 | + | } |
| 1 | 1 | --- |
|
| 2 | 2 | import PageLayout from "@/layouts/Base"; |
|
| 3 | 3 | import MarkdownIt from "markdown-it"; |
|
| 4 | + | import { ReplyContainer } from "@/components/post/ReplyContainer"; |
|
| 4 | 5 | ||
| 5 | 6 | export const prerender = false; |
|
| 6 | 7 | ||
| 145 | 146 | Share |
|
| 146 | 147 | </button> |
|
| 147 | 148 | </div> |
|
| 149 | + | <ReplyContainer client:load atUri={atUri} postTitle={title} /> |
|
| 148 | 150 | </> |
|
| 149 | 151 | )} |
|
| 150 | 152 | </article> |
|
| 1 | 1 | import { Hono } from "hono"; |
|
| 2 | 2 | import { cors } from "hono/cors"; |
|
| 3 | - | import { home, now, auth } from "./routes"; |
|
| 3 | + | import { home, now, auth, guestAuth } from "./routes"; |
|
| 4 | 4 | ||
| 5 | 5 | interface Env { |
|
| 6 | 6 | SESSIONS: KVNamespace; |
|
| 32 | 32 | app.route("/", home); |
|
| 33 | 33 | app.route("/now", now); |
|
| 34 | 34 | app.route("/auth", auth); |
|
| 35 | + | app.route("/guest-auth", guestAuth); |
|
| 35 | 36 | ||
| 36 | 37 | export default app; |
|
| 1 | + | import { Hono } from "hono"; |
|
| 2 | + | import { generateDPoPKeyPair } from "../lib/dpop"; |
|
| 3 | + | import { |
|
| 4 | + | fetchOAuthMetadata, |
|
| 5 | + | generatePKCE, |
|
| 6 | + | generateState, |
|
| 7 | + | sendPAR, |
|
| 8 | + | buildAuthorizationUrl, |
|
| 9 | + | exchangeCodeForTokens, |
|
| 10 | + | refreshAccessToken, |
|
| 11 | + | } from "../lib/oauth"; |
|
| 12 | + | import { |
|
| 13 | + | storeAuthState, |
|
| 14 | + | getAndDeleteAuthState, |
|
| 15 | + | createSession, |
|
| 16 | + | getSession, |
|
| 17 | + | deleteSession, |
|
| 18 | + | getSessionIdFromCookie, |
|
| 19 | + | setSessionCookie, |
|
| 20 | + | clearSessionCookie, |
|
| 21 | + | isTokenExpired, |
|
| 22 | + | updateSession, |
|
| 23 | + | } from "../lib/session"; |
|
| 24 | + | ||
| 25 | + | interface Env { |
|
| 26 | + | SESSIONS: KVNamespace; |
|
| 27 | + | ALLOWED_DID: string; |
|
| 28 | + | PDS_URL: string; |
|
| 29 | + | CLIENT_URL: string; |
|
| 30 | + | API_URL: string; |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | const guestAuth = new Hono<{ Bindings: Env }>(); |
|
| 34 | + | ||
| 35 | + | // OAuth client metadata endpoint for guests |
|
| 36 | + | guestAuth.get("/client-metadata.json", (c) => { |
|
| 37 | + | const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`; |
|
| 38 | + | const redirectUri = `${c.env.API_URL}/guest-auth/callback`; |
|
| 39 | + | ||
| 40 | + | return c.json({ |
|
| 41 | + | client_id: clientId, |
|
| 42 | + | client_name: "Steve Dylan's Blog (Guest)", |
|
| 43 | + | client_uri: c.env.API_URL, |
|
| 44 | + | redirect_uris: [redirectUri], |
|
| 45 | + | grant_types: ["authorization_code", "refresh_token"], |
|
| 46 | + | response_types: ["code"], |
|
| 47 | + | scope: "atproto transition:generic", |
|
| 48 | + | token_endpoint_auth_method: "none", |
|
| 49 | + | application_type: "web", |
|
| 50 | + | dpop_bound_access_tokens: true, |
|
| 51 | + | }); |
|
| 52 | + | }); |
|
| 53 | + | ||
| 54 | + | // Start OAuth login flow for guests |
|
| 55 | + | guestAuth.get("/login", async (c) => { |
|
| 56 | + | try { |
|
| 57 | + | const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`; |
|
| 58 | + | const redirectUri = `${c.env.API_URL}/guest-auth/callback`; |
|
| 59 | + | ||
| 60 | + | // Get optional return URL from query params |
|
| 61 | + | const returnTo = c.req.query("returnTo") || "/now"; |
|
| 62 | + | ||
| 63 | + | // Fetch OAuth metadata from PDS |
|
| 64 | + | const metadata = await fetchOAuthMetadata(c.env.PDS_URL); |
|
| 65 | + | ||
| 66 | + | // Generate PKCE and state |
|
| 67 | + | const pkce = await generatePKCE(); |
|
| 68 | + | const state = generateState(); |
|
| 69 | + | ||
| 70 | + | // Generate DPoP keypair |
|
| 71 | + | const dpopKeyPair = await generateDPoPKeyPair(); |
|
| 72 | + | ||
| 73 | + | // Send PAR request |
|
| 74 | + | const { parResponse, dpopNonce } = await sendPAR( |
|
| 75 | + | metadata, |
|
| 76 | + | clientId, |
|
| 77 | + | redirectUri, |
|
| 78 | + | state, |
|
| 79 | + | pkce, |
|
| 80 | + | dpopKeyPair, |
|
| 81 | + | "atproto transition:generic", |
|
| 82 | + | ); |
|
| 83 | + | ||
| 84 | + | // Store auth state in KV with returnTo URL |
|
| 85 | + | await storeAuthState( |
|
| 86 | + | c.env.SESSIONS, |
|
| 87 | + | state, |
|
| 88 | + | pkce.codeVerifier, |
|
| 89 | + | dpopKeyPair, |
|
| 90 | + | dpopNonce, |
|
| 91 | + | ); |
|
| 92 | + | ||
| 93 | + | // Store returnTo separately to retrieve after callback |
|
| 94 | + | await c.env.SESSIONS.put( |
|
| 95 | + | `guest_return:${state}`, |
|
| 96 | + | returnTo, |
|
| 97 | + | { expirationTtl: 600 }, // 10 minutes |
|
| 98 | + | ); |
|
| 99 | + | ||
| 100 | + | // Build authorization URL and redirect |
|
| 101 | + | const authUrl = buildAuthorizationUrl( |
|
| 102 | + | metadata, |
|
| 103 | + | parResponse.request_uri, |
|
| 104 | + | clientId, |
|
| 105 | + | ); |
|
| 106 | + | return c.redirect(authUrl); |
|
| 107 | + | } catch (error) { |
|
| 108 | + | console.error("Guest login error:", error); |
|
| 109 | + | return c.redirect(`${c.env.CLIENT_URL}/now?error=login_failed`); |
|
| 110 | + | } |
|
| 111 | + | }); |
|
| 112 | + | ||
| 113 | + | // OAuth callback handler for guests |
|
| 114 | + | guestAuth.get("/callback", async (c) => { |
|
| 115 | + | try { |
|
| 116 | + | const code = c.req.query("code"); |
|
| 117 | + | const state = c.req.query("state"); |
|
| 118 | + | const error = c.req.query("error"); |
|
| 119 | + | const errorDescription = c.req.query("error_description"); |
|
| 120 | + | ||
| 121 | + | // Handle OAuth errors |
|
| 122 | + | if (error) { |
|
| 123 | + | console.error("OAuth error:", error, errorDescription); |
|
| 124 | + | return c.redirect( |
|
| 125 | + | `${c.env.CLIENT_URL}/now?error=${encodeURIComponent(error)}`, |
|
| 126 | + | ); |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | if (!code || !state) { |
|
| 130 | + | return c.redirect(`${c.env.CLIENT_URL}/now?error=missing_params`); |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | // Retrieve and validate auth state |
|
| 134 | + | const authState = await getAndDeleteAuthState(c.env.SESSIONS, state); |
|
| 135 | + | if (!authState) { |
|
| 136 | + | return c.redirect(`${c.env.CLIENT_URL}/now?error=invalid_state`); |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | // Get return URL |
|
| 140 | + | const returnTo = |
|
| 141 | + | (await c.env.SESSIONS.get(`guest_return:${state}`)) || "/now"; |
|
| 142 | + | await c.env.SESSIONS.delete(`guest_return:${state}`); |
|
| 143 | + | ||
| 144 | + | const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`; |
|
| 145 | + | const redirectUri = `${c.env.API_URL}/guest-auth/callback`; |
|
| 146 | + | ||
| 147 | + | // Fetch OAuth metadata |
|
| 148 | + | const metadata = await fetchOAuthMetadata(c.env.PDS_URL); |
|
| 149 | + | ||
| 150 | + | // Exchange code for tokens |
|
| 151 | + | const { tokenResponse, dpopNonce } = await exchangeCodeForTokens( |
|
| 152 | + | metadata, |
|
| 153 | + | code, |
|
| 154 | + | authState.codeVerifier, |
|
| 155 | + | clientId, |
|
| 156 | + | redirectUri, |
|
| 157 | + | authState.dpopKeyPair, |
|
| 158 | + | authState.dpopNonce, |
|
| 159 | + | ); |
|
| 160 | + | ||
| 161 | + | // For guests, allow any ATProto account (no DID check) |
|
| 162 | + | // Create session with a "guest_" prefix to differentiate from admin sessions |
|
| 163 | + | const sessionId = await createSession( |
|
| 164 | + | c.env.SESSIONS, |
|
| 165 | + | tokenResponse.access_token, |
|
| 166 | + | tokenResponse.refresh_token || "", |
|
| 167 | + | authState.dpopKeyPair, |
|
| 168 | + | dpopNonce, |
|
| 169 | + | tokenResponse.sub, |
|
| 170 | + | tokenResponse.expires_in, |
|
| 171 | + | ); |
|
| 172 | + | ||
| 173 | + | // Prefix session ID to mark as guest |
|
| 174 | + | const guestSessionId = `guest_${sessionId}`; |
|
| 175 | + | ||
| 176 | + | // Store the original session ID mapping |
|
| 177 | + | await c.env.SESSIONS.put( |
|
| 178 | + | `guest_session:${guestSessionId}`, |
|
| 179 | + | sessionId, |
|
| 180 | + | { expirationTtl: 60 * 60 * 24 * 14 }, // 14 days |
|
| 181 | + | ); |
|
| 182 | + | ||
| 183 | + | // Set session cookie and redirect to return URL |
|
| 184 | + | setSessionCookie(c, guestSessionId, c.env.CLIENT_URL); |
|
| 185 | + | return c.redirect(`${c.env.CLIENT_URL}${returnTo}`); |
|
| 186 | + | } catch (error) { |
|
| 187 | + | console.error("Guest callback error:", error); |
|
| 188 | + | return c.redirect(`${c.env.CLIENT_URL}/now?error=callback_failed`); |
|
| 189 | + | } |
|
| 190 | + | }); |
|
| 191 | + | ||
| 192 | + | // Logout endpoint for guests |
|
| 193 | + | guestAuth.post("/logout", async (c) => { |
|
| 194 | + | const sessionId = getSessionIdFromCookie(c); |
|
| 195 | + | ||
| 196 | + | if (sessionId?.startsWith("guest_")) { |
|
| 197 | + | // Get original session ID |
|
| 198 | + | const originalSessionId = await c.env.SESSIONS.get( |
|
| 199 | + | `guest_session:${sessionId}`, |
|
| 200 | + | ); |
|
| 201 | + | if (originalSessionId) { |
|
| 202 | + | await deleteSession(c.env.SESSIONS, originalSessionId); |
|
| 203 | + | await c.env.SESSIONS.delete(`guest_session:${sessionId}`); |
|
| 204 | + | } |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | clearSessionCookie(c, c.env.CLIENT_URL); |
|
| 208 | + | return c.json({ success: true }); |
|
| 209 | + | }); |
|
| 210 | + | ||
| 211 | + | // Check auth status for guests |
|
| 212 | + | guestAuth.get("/status", async (c) => { |
|
| 213 | + | const sessionId = getSessionIdFromCookie(c); |
|
| 214 | + | ||
| 215 | + | if (!sessionId || !sessionId.startsWith("guest_")) { |
|
| 216 | + | return c.json({ authenticated: false }); |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | // Get original session ID |
|
| 220 | + | const originalSessionId = await c.env.SESSIONS.get( |
|
| 221 | + | `guest_session:${sessionId}`, |
|
| 222 | + | ); |
|
| 223 | + | if (!originalSessionId) { |
|
| 224 | + | clearSessionCookie(c, c.env.CLIENT_URL); |
|
| 225 | + | return c.json({ authenticated: false }); |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | const sessionData = await getSession(c.env.SESSIONS, originalSessionId); |
|
| 229 | + | if (!sessionData) { |
|
| 230 | + | clearSessionCookie(c, c.env.CLIENT_URL); |
|
| 231 | + | await c.env.SESSIONS.delete(`guest_session:${sessionId}`); |
|
| 232 | + | return c.json({ authenticated: false }); |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | const { session, dpopKeyPair } = sessionData; |
|
| 236 | + | ||
| 237 | + | // Check if token needs refresh |
|
| 238 | + | if (isTokenExpired(session.expiresAt) && session.refreshToken) { |
|
| 239 | + | try { |
|
| 240 | + | const metadata = await fetchOAuthMetadata(c.env.PDS_URL); |
|
| 241 | + | const clientId = `${c.env.API_URL}/guest-auth/client-metadata.json`; |
|
| 242 | + | ||
| 243 | + | const { tokenResponse, dpopNonce } = await refreshAccessToken( |
|
| 244 | + | metadata, |
|
| 245 | + | session.refreshToken, |
|
| 246 | + | clientId, |
|
| 247 | + | dpopKeyPair, |
|
| 248 | + | session.dpopNonce, |
|
| 249 | + | ); |
|
| 250 | + | ||
| 251 | + | // Update session with new tokens |
|
| 252 | + | await updateSession( |
|
| 253 | + | c.env.SESSIONS, |
|
| 254 | + | originalSessionId, |
|
| 255 | + | tokenResponse.access_token, |
|
| 256 | + | tokenResponse.refresh_token || session.refreshToken, |
|
| 257 | + | dpopNonce, |
|
| 258 | + | tokenResponse.expires_in, |
|
| 259 | + | ); |
|
| 260 | + | ||
| 261 | + | return c.json({ |
|
| 262 | + | authenticated: true, |
|
| 263 | + | did: session.did, |
|
| 264 | + | handle: session.handle, |
|
| 265 | + | isGuest: true, |
|
| 266 | + | }); |
|
| 267 | + | } catch (error) { |
|
| 268 | + | console.error("Token refresh failed:", error); |
|
| 269 | + | await deleteSession(c.env.SESSIONS, originalSessionId); |
|
| 270 | + | await c.env.SESSIONS.delete(`guest_session:${sessionId}`); |
|
| 271 | + | clearSessionCookie(c, c.env.CLIENT_URL); |
|
| 272 | + | return c.json({ authenticated: false }); |
|
| 273 | + | } |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | return c.json({ |
|
| 277 | + | authenticated: true, |
|
| 278 | + | did: session.did, |
|
| 279 | + | handle: session.handle, |
|
| 280 | + | isGuest: true, |
|
| 281 | + | }); |
|
| 282 | + | }); |
|
| 283 | + | ||
| 284 | + | export default guestAuth; |
| 1 | 1 | export { default as home } from "./home"; |
|
| 2 | 2 | export { default as now } from "./now"; |
|
| 3 | 3 | export { default as auth } from "./auth"; |
|
| 4 | + | export { default as guestAuth } from "./guest-auth"; |
| 23 | 23 | const DID = "did:plc:ia2zdnhjaokf5lazhxrmj6eu"; |
|
| 24 | 24 | const PDS_URL = "https://polybius.social"; |
|
| 25 | 25 | ||
| 26 | + | // Helper function to get session for both admin and guest users |
|
| 27 | + | async function getAnySession(c: any, sessionId: string) { |
|
| 28 | + | if (sessionId.startsWith("guest_")) { |
|
| 29 | + | // Guest session |
|
| 30 | + | const originalSessionId = await c.env.SESSIONS.get( |
|
| 31 | + | `guest_session:${sessionId}`, |
|
| 32 | + | ); |
|
| 33 | + | if (!originalSessionId) return null; |
|
| 34 | + | return await getSession(c.env.SESSIONS, originalSessionId); |
|
| 35 | + | } else { |
|
| 36 | + | // Admin session |
|
| 37 | + | return await getSession(c.env.SESSIONS, sessionId); |
|
| 38 | + | } |
|
| 39 | + | } |
|
| 40 | + | ||
| 26 | 41 | // Create a new post |
|
| 27 | 42 | now.post("/post", async (c) => { |
|
| 28 | 43 | try { |
|
| 297 | 312 | return c.text(errorFeed.rss2(), 200, { |
|
| 298 | 313 | "Content-Type": "application/xml", |
|
| 299 | 314 | }); |
|
| 315 | + | } |
|
| 316 | + | }); |
|
| 317 | + | ||
| 318 | + | // Create a reply to a post (for guests) |
|
| 319 | + | now.post("/reply", async (c) => { |
|
| 320 | + | try { |
|
| 321 | + | // Get session from cookie |
|
| 322 | + | const sessionId = getSessionIdFromCookie(c); |
|
| 323 | + | if (!sessionId) { |
|
| 324 | + | return c.json({ error: "Not authenticated" }, 401); |
|
| 325 | + | } |
|
| 326 | + | ||
| 327 | + | const sessionData = await getAnySession(c, sessionId); |
|
| 328 | + | if (!sessionData) { |
|
| 329 | + | return c.json({ error: "Session not found" }, 401); |
|
| 330 | + | } |
|
| 331 | + | ||
| 332 | + | let { session, dpopKeyPair } = sessionData; |
|
| 333 | + | ||
| 334 | + | // Refresh token if expired |
|
| 335 | + | if (isTokenExpired(session.expiresAt) && session.refreshToken) { |
|
| 336 | + | const metadata = await fetchOAuthMetadata(c.env.PDS_URL); |
|
| 337 | + | const clientId = sessionId.startsWith("guest_") |
|
| 338 | + | ? `${c.env.API_URL}/guest-auth/client-metadata.json` |
|
| 339 | + | : `${c.env.API_URL}/auth/client-metadata.json`; |
|
| 340 | + | ||
| 341 | + | const { tokenResponse, dpopNonce } = await refreshAccessToken( |
|
| 342 | + | metadata, |
|
| 343 | + | session.refreshToken, |
|
| 344 | + | clientId, |
|
| 345 | + | dpopKeyPair, |
|
| 346 | + | session.dpopNonce, |
|
| 347 | + | ); |
|
| 348 | + | ||
| 349 | + | // Get the actual session ID for update |
|
| 350 | + | const actualSessionId = sessionId.startsWith("guest_") |
|
| 351 | + | ? (await c.env.SESSIONS.get(`guest_session:${sessionId}`)) || "" |
|
| 352 | + | : sessionId; |
|
| 353 | + | ||
| 354 | + | // Update session with new tokens |
|
| 355 | + | await updateSession( |
|
| 356 | + | c.env.SESSIONS, |
|
| 357 | + | actualSessionId, |
|
| 358 | + | tokenResponse.access_token, |
|
| 359 | + | tokenResponse.refresh_token || session.refreshToken, |
|
| 360 | + | dpopNonce, |
|
| 361 | + | tokenResponse.expires_in, |
|
| 362 | + | ); |
|
| 363 | + | ||
| 364 | + | // Update local session object |
|
| 365 | + | session.accessToken = tokenResponse.access_token; |
|
| 366 | + | session.dpopNonce = dpopNonce; |
|
| 367 | + | } |
|
| 368 | + | ||
| 369 | + | // Parse request body |
|
| 370 | + | const body = await c.req.json<{ |
|
| 371 | + | parentUri: string; |
|
| 372 | + | content: string; |
|
| 373 | + | }>(); |
|
| 374 | + | ||
| 375 | + | if (!body.parentUri || body.parentUri.trim().length === 0) { |
|
| 376 | + | return c.json({ error: "Parent URI is required" }, 400); |
|
| 377 | + | } |
|
| 378 | + | ||
| 379 | + | if (!body.content || body.content.trim().length === 0) { |
|
| 380 | + | return c.json({ error: "Content is required" }, 400); |
|
| 381 | + | } |
|
| 382 | + | ||
| 383 | + | // Fetch the parent post to get its CID |
|
| 384 | + | const getRecordUrl = |
|
| 385 | + | `${c.env.PDS_URL}/xrpc/com.atproto.repo.getRecord?` + |
|
| 386 | + | new URLSearchParams({ |
|
| 387 | + | repo: body.parentUri.split("/")[2], // Extract DID from URI |
|
| 388 | + | collection: body.parentUri.split("/")[3], // Extract collection |
|
| 389 | + | rkey: body.parentUri.split("/")[4], // Extract rkey |
|
| 390 | + | }); |
|
| 391 | + | ||
| 392 | + | const parentResponse = await fetch(getRecordUrl); |
|
| 393 | + | if (!parentResponse.ok) { |
|
| 394 | + | console.error("Failed to fetch parent post"); |
|
| 395 | + | return c.json({ error: "Failed to fetch parent post" }, 400); |
|
| 396 | + | } |
|
| 397 | + | ||
| 398 | + | const parentData = await parentResponse.json(); |
|
| 399 | + | const parentCid = parentData.cid; |
|
| 400 | + | ||
| 401 | + | // Create the reply record using app.bsky.feed.post lexicon |
|
| 402 | + | const createRecordUrl = `${c.env.PDS_URL}/xrpc/com.atproto.repo.createRecord`; |
|
| 403 | + | ||
| 404 | + | const replyRecord = { |
|
| 405 | + | repo: session.did, |
|
| 406 | + | collection: "app.bsky.feed.post", |
|
| 407 | + | record: { |
|
| 408 | + | $type: "app.bsky.feed.post", |
|
| 409 | + | text: body.content.trim(), |
|
| 410 | + | createdAt: new Date().toISOString(), |
|
| 411 | + | reply: { |
|
| 412 | + | root: { |
|
| 413 | + | uri: body.parentUri, |
|
| 414 | + | cid: parentCid, |
|
| 415 | + | }, |
|
| 416 | + | parent: { |
|
| 417 | + | uri: body.parentUri, |
|
| 418 | + | cid: parentCid, |
|
| 419 | + | }, |
|
| 420 | + | }, |
|
| 421 | + | }, |
|
| 422 | + | }; |
|
| 423 | + | ||
| 424 | + | // Make request with DPoP |
|
| 425 | + | const makeRequest = async (nonce?: string): Promise<Response> => { |
|
| 426 | + | const dpopProof = await createDPoPProof(dpopKeyPair, { |
|
| 427 | + | method: "POST", |
|
| 428 | + | url: createRecordUrl, |
|
| 429 | + | nonce: nonce || session.dpopNonce, |
|
| 430 | + | accessToken: session.accessToken, |
|
| 431 | + | }); |
|
| 432 | + | ||
| 433 | + | return fetch(createRecordUrl, { |
|
| 434 | + | method: "POST", |
|
| 435 | + | headers: { |
|
| 436 | + | "Content-Type": "application/json", |
|
| 437 | + | Authorization: `DPoP ${session.accessToken}`, |
|
| 438 | + | DPoP: dpopProof, |
|
| 439 | + | }, |
|
| 440 | + | body: JSON.stringify(replyRecord), |
|
| 441 | + | }); |
|
| 442 | + | }; |
|
| 443 | + | ||
| 444 | + | let response = await makeRequest(); |
|
| 445 | + | ||
| 446 | + | // Handle DPoP nonce requirement |
|
| 447 | + | if (response.status === 401) { |
|
| 448 | + | const newNonce = extractDPoPNonce(response); |
|
| 449 | + | if (newNonce) { |
|
| 450 | + | // Retry with new nonce |
|
| 451 | + | response = await makeRequest(newNonce); |
|
| 452 | + | ||
| 453 | + | // Get the actual session ID for update |
|
| 454 | + | const actualSessionId = sessionId.startsWith("guest_") |
|
| 455 | + | ? (await c.env.SESSIONS.get(`guest_session:${sessionId}`)) || "" |
|
| 456 | + | : sessionId; |
|
| 457 | + | ||
| 458 | + | // Update session with new nonce |
|
| 459 | + | await updateSession( |
|
| 460 | + | c.env.SESSIONS, |
|
| 461 | + | actualSessionId, |
|
| 462 | + | session.accessToken, |
|
| 463 | + | session.refreshToken, |
|
| 464 | + | newNonce, |
|
| 465 | + | Math.floor((session.expiresAt - Date.now()) / 1000), |
|
| 466 | + | ); |
|
| 467 | + | } |
|
| 468 | + | } |
|
| 469 | + | ||
| 470 | + | if (!response.ok) { |
|
| 471 | + | const errorData = await response.json(); |
|
| 472 | + | console.error("Failed to create reply:", errorData); |
|
| 473 | + | return c.json( |
|
| 474 | + | { error: "Failed to create reply", details: errorData }, |
|
| 475 | + | response.status as 400 | 401 | 403 | 500, |
|
| 476 | + | ); |
|
| 477 | + | } |
|
| 478 | + | ||
| 479 | + | const result = (await response.json()) as { uri: string; cid: string }; |
|
| 480 | + | return c.json({ success: true, uri: result.uri, cid: result.cid }); |
|
| 481 | + | } catch (error) { |
|
| 482 | + | console.error("Error creating reply:", error); |
|
| 483 | + | return c.json({ error: "Internal server error" }, 500); |
|
| 484 | + | } |
|
| 485 | + | }); |
|
| 486 | + | ||
| 487 | + | // Get replies for a post |
|
| 488 | + | now.get("/replies/:uri", async (c) => { |
|
| 489 | + | try { |
|
| 490 | + | const encodedUri = c.req.param("uri"); |
|
| 491 | + | const uri = decodeURIComponent(encodedUri); |
|
| 492 | + | ||
| 493 | + | // Use app.bsky.feed.getPostThread to fetch the post and its replies |
|
| 494 | + | const threadUrl = `${PDS_URL}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}`; |
|
| 495 | + | ||
| 496 | + | const response = await fetch(threadUrl); |
|
| 497 | + | ||
| 498 | + | if (!response.ok) { |
|
| 499 | + | console.error("Failed to fetch thread:", response.status); |
|
| 500 | + | return c.json({ replies: [] }); |
|
| 501 | + | } |
|
| 502 | + | ||
| 503 | + | const data = await response.json(); |
|
| 504 | + | ||
| 505 | + | // Extract replies from the thread |
|
| 506 | + | const replies: any[] = []; |
|
| 507 | + | ||
| 508 | + | if (data.thread?.replies && Array.isArray(data.thread.replies)) { |
|
| 509 | + | for (const reply of data.thread.replies) { |
|
| 510 | + | if (reply.post) { |
|
| 511 | + | replies.push({ |
|
| 512 | + | uri: reply.post.uri, |
|
| 513 | + | cid: reply.post.cid, |
|
| 514 | + | author: { |
|
| 515 | + | did: reply.post.author.did, |
|
| 516 | + | handle: reply.post.author.handle, |
|
| 517 | + | displayName: reply.post.author.displayName, |
|
| 518 | + | avatar: reply.post.author.avatar, |
|
| 519 | + | }, |
|
| 520 | + | record: reply.post.record, |
|
| 521 | + | indexedAt: reply.post.indexedAt, |
|
| 522 | + | replyCount: reply.post.replyCount || 0, |
|
| 523 | + | likeCount: reply.post.likeCount || 0, |
|
| 524 | + | }); |
|
| 525 | + | } |
|
| 526 | + | } |
|
| 527 | + | } |
|
| 528 | + | ||
| 529 | + | return c.json({ replies }); |
|
| 530 | + | } catch (error) { |
|
| 531 | + | console.error("Error fetching replies:", error); |
|
| 532 | + | return c.json({ replies: [] }); |
|
| 300 | 533 | } |
|
| 301 | 534 | }); |
|
| 302 | 535 | ||