[nextjs]Gotosocial + NextAuth(myapp9)

๐Ÿ‘‰๐Ÿป gotosocial์€ ๊ธ€์“ฐ๊ธฐ UI๋ฅผ ์ง€์›๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
GoToSocial does not support a post-writing UI.

๐Ÿ‘‰๐Ÿป ๊ทธ๋ž˜์„œ ๊ธ€์„ ์ž‘์„ฑํ•˜๊ณ  ์ˆ˜์ •ํ•˜๊ณ  ์‚ญ์ œํ•˜๊ธฐ์œ„ํ•ด์„œ๋Š” ๋ณ„๋„์˜ ์•ฑ์ด๋‚˜ ์›น์‚ฌ์ดํŠธ๋ฅผ ์ด์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
Therefore, you need to use a separate app or website to create, edit, and delete posts.

๐Ÿ‘‰๐Ÿป Tusky(๋ชจ๋ฐ”์ผ)๋‚˜ Pinafore(์›น์‚ฌ์ดํŠธ)๋“ฑ์„ ์ด์šฉํ•˜๋ฉด ๊ธ€์„ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
You can write posts using apps like Tusky(mobile) or Pinafore.(website)

๐Ÿ‘‰๐Ÿป ์—ฌ๊ธฐ์„œ๋Š” nextjs์˜ gotosocial์„œ๋ฒ„์˜ nextauth์ธ์ฆ๊ณผ ์„œ๋ฒ„์˜ ๊ธ€์„ ์ฝ๊ณ  ์‚ญ์ œํ•˜๋Š” ๊ธฐ๋Šฅ์— ๋Œ€ํ•ด์„œ ์‚ดํŽด ๋ด…๋‹ˆ๋‹ค.
Here, we examine NextAuth authentication for the GoToSocial server within a Next.js application, as well as the functionality for reading and deleting posts on the server.

๐Ÿ“ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ / Project Structure

myapp9/
โ”œโ”€ auth.ts                               <- ์ธ์ฆ ์„ค์ • / Authentication Settings
โ”œโ”€ app/api/auth/[...nextauth]/route.ts   <- ์ธ์ฆ ์‹คํ–‰ / Authentication Settings
โ”œโ”€ lib/gotosocial.ts                     <- ํƒ€์ž„ ๋ผ์ธ ์ฝ๊ธฐ / Read Timeline
โ”œโ”€ app/api/gts/statuses/[id]/route.ts.   <- ๊ธ€ ์‚ญ์ œ / Delete Post
โ””โ”€ app/feed/page.tsx                     <- UI

๐Ÿ“ Nextjs ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ
Create a Next.js project

npx create-next-app@latest

๐Ÿ“ next-auth ์„ค์น˜
Install next-auth

npm install next-auth@beta

.๐Ÿ“ env.local ํŒŒ์ผ ์ƒ์„ฑ
Create an .env.local file.

# .env.local

# GTS_BASE_URL=https://freelifemakers.com
NEXT_PUBLIC_GTS_BASE_URL=https://freelifemakers.com
GTS_URL="https://freelifemakers.com"

# GTS ID,Secret
AUTH_GOTOSOCIAL_ID=""
AUTH_GOTOSOCIAL_SECRET=""

# npx auth secret  or openssl rand -base64 32 ๋กœ ์ƒ์„ฑ
AUTH_SECRET=
AUTH_URL="http://localhost:3000"

๐Ÿ“ ์•ฑ ๋“ฑ๋กํ•ด์„œ ID/Secret ๋ฐ›๊ธฐ(ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰)
Register the app to obtain the ID/Secret (run in the terminal)

โœ”๏ธ client_id,ย client_secret๋ฅผ .env.local์˜ AUTH_GOTOSOIAL_ID์™€ AUTH_GOTOSOCIAL_SECRET์— ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค.

curl -X POST https://freelifemakers.com/api/v1/apps \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "freelife-local-dev",
    "redirect_uris": "http://localhost:3000/api/auth/callback/gotosocial",
    "scopes": "read write follow push",
    "website": "http://localhost:3000"
  }'

1)http://localhost:3000์—์„œ ์‹คํ–‰ํ•˜๊ธฐ ๋•Œ๋ฌธ์— AUTH_URL=”http://localhost:3000″์ด ๋ฉ๋‹ˆ๋‹ค.
Since it runs on http://localhost:3000, AUTH_URL becomes “http://localhost:3000”.

2)AUTH_SECRET์€ npmx auth secret๋กœ ํ„ฐ๋ฏธ๋„์—์„œ ์ƒ์„ฑ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
You can generate AUTH_SECRET in the terminal using npmx auth secret.

๐Ÿ“ ๋กœ๊ทธ์ธ , ์ฝœ๋ฐฑ API (ํ•ต์‹ฌ)
Login , Callback API (Core)

app/api/auth/[...nextauth]/route.ts 

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers

— myapp9/auth.ts

// auth.ts (ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์— ์ƒ์„ฑ)
// auth.ts (Created in the project root)
import NextAuth from "next-auth"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "gotosocial",
      name: "freelifemakers",
      type: "oauth",
      authorization: {
        url: "https://freelifemakers.com/oauth/authorize",
        params: { scope: "read write follow push" }
      },
      token: "https://freelifemakers.com/oauth/token",
      userinfo: "https://freelifemakers.com/api/v1/accounts/verify_credentials",

      // .env.local๊ณผ ๋ณ€์ˆ˜๋ช… ์ผ์น˜์‹œํ‚ค๊ธฐ
      // Match with variable names in .env.local
      clientId: process.env.AUTH_GOTOSOCIAL_ID,
      clientSecret: process.env.AUTH_GOTOSOCIAL_SECRET,

      // GTS ํ† ํฐ ๋ฐœ๊ธ‰ ์—๋Ÿฌ(400 Bad Request) ํ•ด๊ฒฐ ์ ˆ๋Œ€ ์กฐ๊ฑด
      // Essential condition to resolve GTS token issuance error (400 Bad Request)
      client: {
        token_endpoint_auth_method: "client_secret_post",
      },
      profile(profile: any) {
        return {
          id: profile.id,
          name: profile.display_name || profile.username,
          image: profile.avatar,
          email: profile.acct.includes('@') ? profile.acct : `${profile.acct}@freelifemakers.com`,
        }
      },
    }
  ],
  callbacks: {
    async jwt({ token, account }: any) {
      if (account) token.accessToken = account.access_token
      return token
    },
    async session({ session, token }: any) {
      session.accessToken = token.accessToken
      return session
    }
  }
})

๐Ÿ“ GoToSocial ํด๋ผ์ด์–ธํŠธ ํŒŒ์ผ
GoToSocial client files

โœ”๏ธ lib/gotosocial.tsย ๋งŒ๋“ค๊ธฐ / Create lib/gotosocial.ts

— project/lib/gotosocial.ts

export const GTS_BASE = process.env.GTS_URL!

// ์„ธ์…˜์—์„œ ๊บผ๋‚ธ accessToken์œผ๋กœ ํƒ€์ž„๋ผ์ธ ๊ฐ€์ ธ์˜ค๊ธฐ
// etrieve the timeline using the accessToken obtained from the session
export async function getHomeTimeline(accessToken: string) {
  const res = await fetch(`${GTS_BASE}/api/v1/timelines/home`, {
    headers: { Authorization: `Bearer ${accessToken}` }
  })
  return res.json()
}

1) myapp9/app/fee/page.tsx์—์„œ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
It is called from myapp9/app/fee/page.tsx.

๐Ÿ“ ํ”„๋ก ํŠธ์—”๋“œ / Frontend

— ์„œ๋ฒ„์˜ ๊ธฐ๋Šฅ๊ณผ ํด๋ผ์ด์–ธํŠธ์˜ ๊ธฐ๋Šฅ์€ ๊ฐ™์€ ํŽ˜์ด์ง€์— ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
You cannot write server-side and client-side code on the same page.

— ๊ทธ๋ž˜์„œ ์ฝ”๋“œ๋ฅผ page.tsx์™€ PostCard.tsx๋กœ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
So, I am splitting the code into page.tsx and PostCard.tsx.

โœ”๏ธ myapp9/app/feed/page.tsx

// app/feed/page.tsx
import { auth } from "@/auth"
import { getHomeTimeline } from "@/lib/gotosocial" // /lib/gotosocial.ts
import { PostCard } from './PostCard' // Post,Delete Card Component


export default async function Feed() {
  // NextAuth v5 ์ „์šฉ ์„ธ์…˜ ์ถ”์ถœ
  // Extract session for NextAuth v5
  const session = await auth() 

  // ํ† ํฐ์ด ์„ธ์…˜์— ์•ˆ์ „ํ•˜๊ฒŒ ๋“ค์–ด์™”๋Š”์ง€ ๊ฒ€์ฆ
  // Verify if the token has been safely retrieved in the session
  if (!session?.accessToken) {
    return (
      <div className="p-10 text-center">
        <p className="text-red-500 font-semibold mb-2">์ธ์ฆ ํ† ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. / Unable to find authentication token.</p>
        <a href="/api/auth/signin" className="text-blue-500 underline">๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ / Go to login page</a>
      </div>
    )
  }


  try {

    // /lib/gotosocial.ts์˜ getHomeTimeline() ํ•จ์ˆ˜ ์‚ฌ์šฉ
    // Use the getHomeTimeline() function from /lib/gotosocial.ts
    const posts = await getHomeTimeline(session.accessToken)

    return (
      <div className="max-w-xl mx-auto p-4 space-y-4">
        <h1 className="text-xl font-bold border-b pb-2">์—ฐํ•ฉ ์šฐ์ฃผ ํƒ€์ž„๋ผ์ธ</h1>
        <h1 className="text-sm text-gray-500"> Federated Universe Timeline </h1>
        {posts.length === 0 ? (
          <p className="text-gray-500 py-10 text-center">์•„์ง ํƒ€์ž„๋ผ์ธ์— ํ‘œ์‹œํ•  ๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค. / No posts to display yet.</p>
        ) : (
          posts.map((p: any) => <PostCard key={p.id} post={p} token={session.accessToken} />)
        )}
      </div>
    )
  } catch (error) {
    console.error("ํ”ผ๋“œ ๋กœ๋”ฉ ์ค‘ ์น˜๋ช…์  ์—๋Ÿฌ / Fatal error while loading feed:", error);
    return <div className="p-10 text-red-500">GTS ๋ฐฑ์—”๋“œ ์„œ๋ฒ„์™€ ํ†ต์‹ ํ•˜๋Š” ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. / Error occurred while communicating with GTS backend.</div>
  }
}

// signout  : http://localhost:3000/api/auth/signout
// signin  : http://localhost:3000/api/auth/signin

โœ”๏ธ myapp9/app/feed/PostCard.tsx

// app/feed/PostCard.tsx
'use client'
import { deleteStatus } from '@/lib/gotosocial'

export function PostCard({ post, token }: { post: any, token: string }) {
  return (
    <div className="border rounded-xl p-4 bg-white shadow-sm space-y-2">
      <div className="flex items-center space-x-2">
        <img src={post.account.avatar} className="w-8 h-8 rounded-full" />
        <span className="font-bold text-sm">@{post.account.acct}</span>
      </div>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      
      <button 
        onClick={async () => {
          if(!confirm('์‚ญ์ œ?')) return
          await fetch(`/api/gts/statuses/${post.id}`, { method: 'DELETE' })
          location.reload()
        }}
        className="text-red-500 text-sm"
      >
        ์‚ญ์ œ/Delete
      </button>
    </div>
  )
}

๐Ÿ“ ๊ธ€ ์‚ญ์ œ / Delete Post

— myapp9/api/gts/statuses/[id]/route.ts

// app/api/gts/statuses/[id]/route.ts
import { auth } from "@/auth"

// ์‚ญ์ œ / Delete
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const session = await auth()
  if (!session?.accessToken) return new Response("Unauthorized", { status: 401 })

  const baseUrl = process.env.GTS_URL || "https://freelifemakers.com"
  const res = await fetch(`${baseUrl}/api/v1/statuses/${id}`, {
    method: 'DELETE',
    headers: { Authorization: `Bearer ${session.accessToken}` }
  })

  return new Response(null, { status: res.status })
}

1)๊ธ€ ์‚ญ์ œ ๋ถ€๋ถ„ ๋ผ์šฐํ„ฐ๋ฅผ ๋”ฐ๋กœ ๋งŒ๋“  ์ด์œ ๋Š” ์„œ๋ฒ„์˜ CORS์—๋Ÿฌ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
I created a separate router for the post deletion functionality due to a server-side CORS error.

2)gotosocial.ts์— ๊ธ€์‚ญ์ œ ํ•จ์ˆ˜๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ PostCard.tsx์—์„œ ํ˜ธ์ถœํ•˜๋ฉด PostCard.tsx๊ฐ€ ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ๋˜๊ธฐ ๋–„๋ฌธ์— ์„œ๋ฒ„์—์„œ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค.
If the post deletion function is located in gotosocial.ts and called from PostCard.tsx, the server will block the request because PostCard.tsx acts as the client (browser).

3)์„œ๋ฒ„์™€ ์„œ๋ฒ„๋ผ๋ฆฌ๋Š” CORS๋ฌธ์ œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
There are no CORS issues between servers.

4) ๊ทธ๋ž˜์„œ PostCard.tsx๊ฐ€ ์‹คํ–‰ํ•œ ๋ผ์šฐํŠธ์—์„œ gotosocial์„œ๋ฒ„์— ์‚ญ์ œ์š”์ฒญ์„ ํ•ฉ๋‹ˆ๋‹ค.
Therefore, the route executed by PostCard.tsx sends a deletion request to the GoToSocial server.

๐Ÿ“ ์‹คํ–‰ / Start Server

npm run dev

๐Ÿ“ ๋ธŒ๋ผ์šฐ์ € ์ ‘์† / Access Browser

http://localhost:3000/feed
http://localhost:3000/feed
Delete Post

Leave a Reply