[nextjs]SNS Server-45(myapp55)Β 

πŸ‘‰πŸ» myapp45μ—μ„œλŠ” Tursky둜그인 뢀뢄을 κ΅¬ν˜„ν•©λ‹ˆλ‹€.
In myapp45, the Tursky login functionality is implemented.

πŸ‘‰πŸ» μ›Ήμ΄λ‚˜ pinaforeμ—μ„œ μ •μƒμ μœΌλ‘œ λͺ¨λ“ λ°μ΄ν„°κ°€ ν‘œμ‹œλ©λ‹ˆλ‹€.
All data is displayed correctly on the web or in Pinafore.

πŸ‘‰πŸ» ν•˜μ§€λ§Œ Tuskyμ•± νƒ€μž„λΌμΈμ— 데이터가 μ •μƒμ μœΌλ‘œ ν‘œμ‹œλ˜μ§€ μ•ŠλŠ” λ¬Έμ œκ°€ μžˆμŠ΅λ‹ˆλ‹€.
However, there is an issue where data is not displaying correctly on the Tusky app’s timeline.

πŸ‘‰πŸ» μΆ”ν›„ μ‚΄νŽ΄ 보도둝 ν•˜κ³  μ—¬κΈ°μ„œλŠ” 앱에 둜그인 μ™„λ£Œν•˜κ³  404였λ₯˜ μ—†μ• λŠ” λΆ€λΆ„κΉŒμ§€λ§Œ μ™„λ£Œν•˜κ² μŠ΅λ‹ˆλ‹€.
We will look into that later; for now, we will just complete the steps of logging into the app and resolving the 404 error.

πŸ‘‰πŸ» 전체 μ½”λ“œλŠ” κΉƒν—ˆλΈŒμ—μ„œ 확인 ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
You can find the full code on GitHub.

https://github.com/gideonslife01/flm-nextjs

πŸ“ 전체 ν”„λ‘œμ νŠΈ ꡬ쑰 / Overall Project Structure

myapp project/  
β”œβ”€β”€ app/  (Next.js App Router)
β”‚   β”œβ”€β”€ .well-known/webfinger/route.ts  -> webfinger
β”‚   β”œβ”€β”€ api/follow/route.ts    -> Follow API(temporary)
β”‚   β”œβ”€β”€ api/announce/route.ts  -> Boost(Announcement)
β”‚   β”œβ”€β”€ api/like/route.ts.     -> Like API
β”‚   β”œβ”€β”€ api/posts/route.ts     -> Writing API(deprecated)
β”‚   β”œβ”€β”€ api/timeline/route.ts  -> Timeline API
β”‚   β”œβ”€β”€ api/followinglist/route.ts  -> followinglist API
β”‚   β”œβ”€β”€ api/followerslist/route.ts  -> followerslist API
β”‚   β”œβ”€β”€ api/v1/instance/route.ts -> auth
β”‚   β”œβ”€β”€ api/v1/apps/route.ts.    -> auth
β”‚   β”œβ”€β”€ api/v1/accounts/verify_credentials/route.ts -> auth
β”‚   β”œβ”€β”€ api/v1/statuses/route.ts -> Write Post
β”‚   β”œβ”€β”€ api/v1/timelines/home/route.ts -> timeline
β”‚   β”œβ”€β”€ api/v1/media/route.ts -> Image upload
β”‚   β”œβ”€β”€ api/v1/media/[id]/route.ts  -> Image Delete 
β”‚   β”œβ”€β”€ api/v1/instance/rules/route.ts
β”‚   β”œβ”€β”€ api/v1/instance/domain_blocks/route.ts 
β”‚   β”œβ”€β”€ api/v1/filters/route.ts
β”‚   β”œβ”€β”€ api/v1/lists/route.ts 
β”‚   β”œβ”€β”€ api/v1/announcements/route.ts
β”‚   β”œβ”€β”€ api/v1/timelines/public/route.ts 
β”‚   β”œβ”€β”€ oauth/authorize/route.ts -> auth
β”‚   β”œβ”€β”€ oauth/token/route.ts -> auth
β”‚   β”œβ”€β”€ oauth/login-and-authorize/route.ts -> Tursky,Pinafore login processing
β”‚   β”œβ”€β”€ oauth/revoke/route.ts    
β”‚   β”œβ”€β”€ api/v1/search/home/route.ts -> search
β”‚   β”œβ”€β”€ api/v2/search/home/route.ts -> search
β”‚   β”œβ”€β”€ api/v2/notifications/policy/route.ts
β”‚   β”œβ”€β”€ api/v1/accounts/[id]/followers/route.ts -> followers
β”‚   β”œβ”€β”€ api/v1/accounts/[id]/following/route.ts -> following
β”‚   β”œβ”€β”€ api/v1/statuses/[id]/route.ts -> -> Post delete(pinafore)
β”‚   β”œβ”€β”€ api/auth/signup/route.ts        -> Website Signup
β”‚   β”œβ”€β”€ api/auth/login/route.ts         -> Website Login
β”‚   β”œβ”€β”€ api/auth/logout/route.ts        -> Website Logout
β”‚   β”œβ”€β”€ api/auth/refresh/route.ts       -> Refresh Token
β”‚   β”œβ”€β”€ api/cleanup-orphan/route.ts     -> post,outbox clean up
β”‚   β”œβ”€β”€ api/auth/me/route.tsx           -> Login Check
β”‚   β”œβ”€β”€ auth/signup/page.tsx            -> Signup UI
β”‚   β”œβ”€β”€ auth/signup/page.tsx            -> Login UI        
β”‚   β”œβ”€β”€ users/[username]/
β”‚   β”‚   β”œβ”€β”€ statuses/[id]/route.ts -> Indivisual Post
β”‚   β”‚   β”œβ”€β”€ route.ts               -> Acotr Information
β”‚   β”‚   β”œβ”€β”€ followers/route.ts     -> Followers List
β”‚   β”‚   β”œβ”€β”€ following/route.ts     -> Following List
β”‚   β”‚   β”œβ”€β”€ inbox/route.ts         -> Inbox
β”‚   β”‚   └── outbox/route.ts        -> outbox
β”‚   β”œβ”€β”€ usersui/[username]/
β”‚   β”‚   β”œβ”€β”€ page.tsx               -> Timeline UI(Server, 404)
β”‚   β”‚   β”œβ”€β”€ ClientPage.tsx.        -> Timeline UI(Client,Call Active Themes)
β”‚   β”‚   └── _components/themes/
β”‚   β”‚       β”œβ”€β”€ themeex/ThemeexTheme.tsx   -> Example Theme
β”‚   β”‚       β”œβ”€β”€ pinafore/PinaforeTheme.tsx -> Theme 1
β”‚   β”‚       β”œβ”€β”€ pinafore/FollowersList.tsx -> Theme 1, FollowersList
β”‚   β”‚       β”œβ”€β”€ pinafore/FollowingList.tsx -> Theme 1, FollowingList
β”‚   β”‚       β”œβ”€β”€ pinafore/NotificationsList.tsx -> Theme 1, NotificationsList
β”‚   β”‚       β”œβ”€β”€ pinafore/TimelineList.tsx  -> Theme 1, TimelineList
β”‚   β”‚       β”œβ”€β”€ mastodon/MastodonTheme.tsx -> Theme 2
β”‚   β”‚       └── minimal/MinimaltTheme.tsx  -> Theme 3
β”‚   β”œβ”€β”€ layout.tsx, page.tsx, globals.css
β”‚   └── favicon.ico
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ theme.tsx              -> Theme Provider
β”‚   β”œβ”€β”€ watchThemes.ts         -> Check real-time theme changes
β”‚   β”œβ”€β”€ auth.ts                -> Authentication, User Management
β”‚   β”œβ”€β”€ ap.ts                  -> Follow,Undo,Create,Likes,Announce
β”‚   β”œβ”€β”€ visibility.ts          -> Visibility(Public,Unlisted,Private,Direct)
β”‚   └── db.ts                  -> DB connection
β”œβ”€β”€ data/
β”‚   β”œβ”€β”€ keys/userIDs/          -> private.pem, public.pem(New)
β”‚   └── keys/                  -> private.pem, public.pem(legacy)
β”œβ”€β”€ data.sqlite                -> Database(1/3)
β”œβ”€β”€ data.sqlite-wal            -> Database(2/3)
β”œβ”€β”€ data.sqlite-shm            -> Database(3/3)
β”œβ”€β”€ Caddyfile                  -> https 
β”œβ”€β”€ instrumentation.ts         -> Background Server
└── package.json

πŸ“ ν”„λ‘œμ νŠΈ μ‹œμž‘ / Project Start


πŸ“ μ½”λ“œμˆ˜μ • / Code Modification

βœ”οΈ Tusky앱에 둜그인 κ΅¬ν˜„ / Implementing login for the Tusky app

1) app/oauth/authorize/route.ts

A. ν˜„μž¬ λͺ¨λ°”일앱과 μ›Ήμ‚¬μ΄νŠΈμΈκ²½μš° λ™μΌν•œ μ½”λ“œλ‘œ μž‘λ™ν•˜μ§€ μ•ŠκΈ° λ•Œλ¬Έμ— λΆ„λ¦¬μ μš©
Currently, the mobile app and the website do not operate on the same code, so they are implemented separately.

B. fetch방식이 μ•„λ‹Œ methd=POST 방식 적용
Applied the POST method instead of the fetch method.

— 둜그인 νŽ˜μ΄μ§€λ‘œ(/auth/login/page.tsx)μ΄λ™ν•˜μ§€ μ•Šκ³  이 λΌμš°νŠΈμ—μ„œ 둜그인 νŽ˜μ΄μ§€ 보여주기
Display the login page on this route without navigating to the login page (/auth/login/page.tsx).


  // βœ… for CORS error
  // if (!username) {
  //     const loginUrl = new URL('/auth/login', ORIGIN); // βœ… 도메인, not loacalhost / Domain, not localhost
  //     const realNextUrl = `${ORIGIN}${url.pathname}${url.search}`; // βœ… Use the domain for Next.js, not localhost.
  //     loginUrl.searchParams.set('next', realNextUrl);
  //     return NextResponse.redirect(loginUrl.toString());
  // }

  // βœ… myapp55 μˆ˜μ • - 둜그인 μ•ˆ 됐으면 /auth/login으둜 μ΄λ™ν•˜μ§€ 말고 μ—¬κΈ°μ„œ 둜그인 폼 보여주기
  //  If not logged in, do not redirect to /auth/login, but show the login form here.
  if (!username) {
    const html = `
    <!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Login</title>
    <style>body{font-family:sans-serif;max-width:400px;margin:50px auto;padding:20px}input{width:90%;padding:10px;margin:8px 0;font-size:16px}button{width:100%;padding:12px;background:#6364ff;color:white;border:0;border-radius:8px;font-size:16px}</style>
    </head><body>
      <h2>πŸ” 둜그인 ν•„μš”(OAuth)</h2>
      <p>${clientId} μ•± 승인 μœ„ν•΄ 둜그인</p>
      <form method="POST" action="/oauth/login-and-authorize">
        <input type="hidden" name="client_id" value="${clientId}">
        <input type="hidden" name="redirect_uri" value="${redirectUri}">
        <input type="hidden" name="scope" value="${scope}">
        <input name="username" placeholder="username" required>
        <input name="password" type="password" placeholder="password" required>
        <button type="submit">둜그인 & 승인</button>
      </form>
    </body></html>`;
    return new NextResponse(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
  }

βœ”οΈapp/oauth/login-and-authorize/route.ts – 라우트 μΆ”κ°€ / Add route

— Tursky앱에 λ‘œκ·ΈμΈμ„ μœ„ν•œ 라우트,둜그인 처리
Route and login handling for the Tursky app

— api/auth/login/route.tsλŠ” μ›Ήμ‚¬μ΄νŠΈμš© 둜그인 처리
api/auth/login/route.ts handles login for the website.

... ...
export async function POST(req: Request) {
... ...
  // - TuskyλŠ” HTML+JS둜, κ·Έ μ™ΈλŠ” 302 / Tusky uses HTML+JS, others use 302
  let html: string;
  if (finalUrl.startsWith('oauth2redirect://') || finalUrl.includes('tusky')) {
    html = `<!DOCTYPE html><html><body style="text-align:center;padding:50px"><h2>βœ… ${user.username} 승인됨</h2><p><a href="${finalUrl}">${finalUrl}</a></p><script>setTimeout(()=>{window.location.href="${finalUrl}"},300)</script></body></html>`;
  } else {
    html = `<!DOCTYPE html><html><body><script>window.location.href="${finalUrl}"</script><a href="${finalUrl}">${finalUrl}</a></body></html>`;
  }

  const res = new NextResponse(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
  res.cookies.set('token', accessToken, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60*15, path: '/' });
  res.cookies.set('refresh_token', refreshToken, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60*60*24*7, path: '/' });
  return res;
}

βœ”οΈ 404 였λ₯˜ λ©”μ„Έμ§€ 처리

1)app/api/v1/instance/rules/route.ts – 라우트 μΆ”κ°€ / Add route

import { NextResponse } from 'next/server';
export async function GET() {
  return NextResponse.json([], { headers: { 'Access-Control-Allow-Origin': '*' } });
}

2)app/api/v1/instance/domain_blocks/route.ts – 라우트 μΆ”κ°€ / Add route

app/api/v1/instance/rules/route.ts μ½”λ“œμ™€ 동일
Identical to the code in app/api/v1/instance/rules/route.ts

3)app/oauth/revoke/route.ts – 라우트 μΆ”κ°€ / Add route

A.Tusky λ‘œκ·Έμ•„μ›ƒ λ•Œ 호좜 / Add route (called upon Tusky logout)

import { NextResponse } from 'next/server';
import db from '@/lib/db';

export async function POST(req: Request) {
  try {
    const body = await req.json().catch(() => ({})) as any;
    const token = body.token;
    if (token) {
      try {
        db.prepare(`DELETE FROM oauth_tokens WHERE access_token=?`).run(token);
        db.prepare(`DELETE FROM sessions WHERE access_token=?`).run(token);
      } catch {}
    }
  } catch {}
  return NextResponse.json({});
}
export async function GET() { return NextResponse.json({}); }

4)app/api/v2/notifications/policy/route.ts – 라우트 μΆ”κ°€ / Add route

import { NextResponse } from 'next/server';
export async function GET() {
  return NextResponse.json({ for_not_following: 'accept', for_not_followers: 'accept', for_new_accounts: 'accept', for_private_mentions: 'accept', for_limited_accounts: 'accept' });
}

5)public/icon.png – νŒŒμΌμΆ”κ°€ / add file

6) app/api/v1/filters/route.ts – 라우트 μΆ”κ°€ / Add route

import { NextResponse } from 'next/server';
export async function GET() {
  return NextResponse.json([], { headers: { 'Access-Control-Allow-Origin': '*' } });
}

7)app/api/v1/lists/route.ts – 라우트 μΆ”κ°€ / Add route

app/api/v1/instance/rules/route.ts μ½”λ“œμ™€ 동일
Identical to the code in app/api/v1/instance/rules/route.ts

8)app/api/v1/announcements/route.ts – 라우트 μΆ”κ°€ / Add route

app/api/v1/instance/rules/route.ts μ½”λ“œμ™€ 동일
Identical to the code in app/api/v1/instance/rules/route.ts

9)app/api/v1/timelines/public/route.ts – 라우트 μΆ”κ°€ / Add route

app/api/v1/instance/rules/route.ts μ½”λ“œμ™€ 동일
Identical to the code in app/api/v1/instance/rules/route.ts

10)app/api/v1/notifications/route.ts – μ½”λ“œμˆ˜μ • / Code Modification

A.라우트 λ¬΄ν•œ 호좜 λ©ˆμΆ”κΈ° / Stop infinite route calls

B. /api/v1/notifications λ¬΄ν•œ 호좜 μ—λŸ¬ λ©”μ„Έμ§€

likes table rows=0 for user1
πŸ”” notifications return=2 follow=2 fav=0 reblog=0
 GET /api/v1/notifications?max_id=follow_1_https%3A%2F%2Ffreelifemakers.com%2Fusers%2Fuser1%2Ffollow%2F01HW187GFP316GG213ASB2DG3T&limit=30 200 in 16ms (next.js: 13ms, proxy.ts: 0.8ms, application-code: 3ms)
 GET /icon.png 404 in 25ms (next.js: 16ms, application-code: 10ms)
❀ likes table rows=0 for user1
πŸ”” notifications return=2 follow=2 fav=0 reblog=0
 GET /api/v1/notifications?limit=30 200 in 4ms (next.js: 0.9ms, proxy.ts: 1.0ms, application-code: 1.7ms)
❀ likes table rows=0 for user1

C. μ½”λ“œ / Code

... ...
export async function GET(req: Request) {
... ...
    // βœ… myapp55 - Tuskyκ°€ max_id둜 λ‹€μŒ νŽ˜μ΄μ§€ 달라고 ν•˜λ©΄ 빈 λ°°μ—΄ 리턴 = 끝
    // Tuskyκ°€ max_id둜 λ‹€μŒ νŽ˜μ΄μ§€ 달라고 ν•˜λ©΄ 빈 λ°°μ—΄ 리턴 = 끝
    // Tursky requests the next page with max_id, return an empty array = end
    const max_id = searchParams.get('max_id'); 
    if (max_id) {
      return NextResponse.json([], {
        headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }
      });
    }
... ...
}
... ...

11) app/api/v1/markers/route.ts – 라우트 μΆ”κ°€ / Add route

import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({
    notifications: { last_read_id: "0", version: 1, updated_at: new Date().toISOString() },
    home: { last_read_id: "0", version: 1, updated_at: new Date().toISOString() }
  }, { headers: { 'Access-Control-Allow-Origin': '*' } });
}

export async function POST(req: Request) {
  const body = await req.json().catch(()=>({}));
  return NextResponse.json(body, { headers: { 'Access-Control-Allow-Origin': '*' } });
}

12)app/api/v1/timelines/home/route.ts – μ½”λ“œμˆ˜μ • / code modification

    // βœ… myapp55 - 계정 정보 ꡬ성 / Account information configuration
    const baseAccount = isMy
    ? { 
        id: '1', username, acct: `${username}@${DOMAIN}`, display_name: username,
        avatar: `https://${DOMAIN}/icon.png`,
        avatar_static: `https://${DOMAIN}/icon.png`,
        header: `https://${DOMAIN}/header.png`,
        header_static: `https://${DOMAIN}/header.png`,
      }
    : (() => {
        let uname = 'user1', dom = 'freelifemakers.com', display = 'user1';
        if (row.actor?.includes('mastodon.social')) { uname = 'freelifemakers'; dom = 'mastodon.social'; display = 'freelifemakers'; }
        return { 
          id: `remote_${dom.replace(/\./g,'_')}_${uname}`, 
          username: uname, acct: `${uname}@${dom}`, display_name: display,
          avatar: `https://${DOMAIN}/icon.png`,
          avatar_static: `https://${DOMAIN}/icon.png`,
          header: `https://${DOMAIN}/header.png`,
          header_static: `https://${DOMAIN}/header.png`,
        };
      })();

    return {
      id: String(row.id),
      uri: longId,
      url: longId,
      account: baseAccount,
      content: content.startsWith('<')? content : `<p>${content}</p>`,
      created_at: new Date(parseDate(row.created_at)).toISOString(),
      visibility: 'public',
      reblogs_count,      // 전체 λΆ€μŠ€νŠΈ 카운트 / Full boost count
      favourites_count,   // 전체 μ’‹μ•„μš” 카운트 / Full like count 
      replies_count: 0,
      favourited: !!favourited, // 전체 μ’‹μ•„μš” / Full like
      reblogged: !!reblogged,   // 전체 λΆ€μŠ€νŠΈ / Full Boost
      muted: false, bookmarked: false, pinned: false,
      // βœ… myapp55 - Tusky ν•„μˆ˜ / Required for Tusky 
      sensitive: false, spoiler_text: '',
      media_attachments: [],
      mentions: [],
      tags: [],
      emojis: [],
      card: null,
      poll: null,
      reblog: null,
    };

πŸ“ ν…ŒμŠ€νŠΈ / Test

βœ”οΈ μ„œλ²„λ‘œκ·Έμ—μ„œ 404 μ—λŸ¬ 확인 / Check for 404 errors in the server logs.

Server log

βœ”οΈ μ™ΈλΆ€ μ•± 둜그인폼 / External App Login Form

βœ”οΈ μ™ΈλΆ€ μ•± 둜그인 폼 – λΉ„λ°€λ²ˆν˜Έ ν‹€λ¦°κ²½μš° / External App Login Form – Wrong Password

βœ”οΈ Tuskyμ•± 둜그인 및 ν”„λ‘œν•„ 확인 / Logging into the Tusky app and checking your profile

μš”ν•œλ³΅μŒ 8μž₯ 32절 / John 8:32

“그리고 λ„ˆν¬λŠ” 진리λ₯Ό μ•Œκ²Œ 될 것이며, 진리가 λ„ˆν¬λ₯Ό 자유둭게 ν•  것이닀.”

“Then you will know the truth ,and the truth will set you free”

Leave a Reply