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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ PUBLIC_URL=
# In development, falls back to public client mode if not set
ATPROTO_JWK_PRIVATE=

# Service account (app password) for the plresearch.org repo.
# Reused by the pages/opportunity-spaces editors AND blog comments — reader
# comments are stored in this repo (single-writer) after the commenter's
# Bluesky OAuth identity is verified server-side.
ATPROTO_HANDLE=
ATPROTO_PASSWORD=

# Admin DID — the ATProto identity that can manage the curated list
# NEXT_PUBLIC_ prefix makes it available in both server and client code
NEXT_PUBLIC_ADMIN_DID=did:plc:pgwr6hkosgznfl5nz7egajei
Expand Down
Binary file added docs/screenshots/bluesky-comments.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
125 changes: 125 additions & 0 deletions src/app/api/comments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server"
import { getSession } from "@/lib/session"
import { ADMIN_DIDS } from "@/lib/lexicons"
import {
listComments,
createComment,
deleteComment,
getComment,
MAX_COMMENT_LENGTH,
} from "@/lib/comments"
import type { CommentAuthor } from "@/lib/lexicons"

export const dynamic = "force-dynamic"

/**
* GET /api/comments?subject=<slug> — list comments for a blog post. Public.
*/
export async function GET(req: NextRequest) {
const subject = req.nextUrl.searchParams.get("subject")?.trim()
if (!subject) {
return NextResponse.json({ error: "subject is required" }, { status: 400 })
}
try {
const comments = await listComments(subject)
return NextResponse.json({ comments })
} catch (error) {
console.error("Failed to list comments:", error)
return NextResponse.json(
{ error: "Failed to load comments" },
{ status: 500 },
)
}
}

/**
* POST /api/comments — create a comment. Requires a Bluesky OAuth session.
* The author identity is taken from the verified session, never the request
* body, so commenters cannot impersonate one another.
*/
export async function POST(req: NextRequest) {
const session = await getSession()
if (!session.did) {
return NextResponse.json(
{ error: "Sign in with Bluesky to comment" },
{ status: 401 },
)
}

let body: { subject?: string; text?: string }
try {
body = await req.json()
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 })
}

const subject = body.subject?.trim()
const text = body.text?.trim()
if (!subject) {
return NextResponse.json({ error: "subject is required" }, { status: 400 })
}
if (!text) {
return NextResponse.json({ error: "Comment cannot be empty" }, { status: 400 })
}
if (text.length > MAX_COMMENT_LENGTH) {
return NextResponse.json(
{ error: `Comment must be ${MAX_COMMENT_LENGTH} characters or fewer` },
{ status: 400 },
)
}

const author: CommentAuthor = {
did: session.did,
handle: session.handle || session.did,
displayName: session.displayName,
avatar: session.avatar,
}

try {
const comment = await createComment(subject, text, author)
return NextResponse.json({ comment }, { status: 201 })
} catch (error) {
console.error("Failed to create comment:", error)
const message =
error instanceof Error && error.message.includes("not configured")
? "Comments are not configured on this deployment"
: "Failed to post comment"
return NextResponse.json({ error: message }, { status: 500 })
}
}

/**
* DELETE /api/comments?rkey=<rkey> — remove a comment. Allowed for the comment
* author or any admin DID.
*/
export async function DELETE(req: NextRequest) {
const session = await getSession()
if (!session.did) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

const rkey = req.nextUrl.searchParams.get("rkey")?.trim()
if (!rkey) {
return NextResponse.json({ error: "rkey is required" }, { status: 400 })
}

try {
const existing = await getComment(rkey)
if (!existing) {
return NextResponse.json({ error: "Comment not found" }, { status: 404 })
}
const isAuthor = existing.record.author.did === session.did
const isAdmin = ADMIN_DIDS.includes(session.did)
if (!isAuthor && !isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await deleteComment(rkey)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to delete comment:", error)
return NextResponse.json(
{ error: "Failed to delete comment" },
{ status: 500 },
)
}
}
7 changes: 7 additions & 0 deletions src/app/api/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,20 @@ export async function POST(request: NextRequest) {
const client = await getGlobalOAuthClient()
const body = await request.json()
const handle = body?.handle
// Optional same-origin path to return to after login (e.g. the blog post
// the reader was on when they clicked "Sign in to comment").
const returnTo =
typeof body?.returnTo === 'string' && body.returnTo.startsWith('/')
? body.returnTo
: undefined

if (typeof handle !== 'string' || !isValidHandle(handle)) {
return NextResponse.json({ error: 'Invalid handle' }, { status: 400 })
}

const url = await client.authorize(handle, {
scope: 'atproto transition:generic',
state: returnTo,
})

return NextResponse.json({ redirectUrl: url.toString() })
Expand Down
13 changes: 10 additions & 3 deletions src/app/api/oauth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ export async function GET(request: NextRequest) {

// Retry OAuth callback up to 3 times for network errors
let oauthSession
let returnTo: string | undefined
let lastError

for (let attempt = 1; attempt <= 3; attempt++) {
try {
const result = await client.callback(params)
oauthSession = result.session
// `state` carries the optional same-origin return path set at login.
if (typeof result.state === 'string' && result.state.startsWith('/')) {
returnTo = result.state
}
break
} catch (error) {
lastError = error
Expand Down Expand Up @@ -68,11 +73,13 @@ export async function GET(request: NextRequest) {
session.avatar = avatar
await session.save()

// Redirect to home
// Redirect back to where the user started (if a safe same-origin path was
// provided), otherwise home.
const requestUrl = new URL(request.url)
const origin = requestUrl.origin

return Response.redirect(`${origin}/`, 303)
const destination = returnTo ? `${origin}${returnTo}` : `${origin}/`

return Response.redirect(destination, 303)
} catch (error) {
console.error('OAuth callback failed:', error)
const requestUrl = new URL(request.url)
Expand Down
4 changes: 4 additions & 0 deletions src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { blogPosts } from '@/lib/content'
import { formatDate } from '@/lib/format'
import AuthorCard from '@/components/AuthorCard'
import Breadcrumb from '@/components/Breadcrumb'
import Comments from '@/components/Comments'

type Props = { params: Promise<{ slug: string }> }

Expand Down Expand Up @@ -58,6 +59,9 @@ export default async function BlogPostPage({ params }: Props) {
{post.html && (
<div className="page-content text-base text-gray-700 leading-relaxed max-w-3xl" dangerouslySetInnerHTML={{ __html: post.html }} />
)}
{/* External stub posts live on their original home; only host comments
for posts that actually live on this site. */}
{!post.external_url && <Comments subject={post.slug} />}
</div>
)
}
Loading