[nextjs]SNS Server-3(myapp13)

๐Ÿ‘‰๐Ÿป public key๊ฒ€์ฆ ๋ถ€๋ถ„์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.
I have added the public key verification logic.

๐Ÿ‘‰๐Ÿป ๊ธ€์„ ๋ฐฐ๋‹ฌํ•˜๋Š” ๊ธฐ๋Šฅ์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
A feature for delivering posts has been added.

๐Ÿ‘‰๐Ÿป ์ด์ „ ํฌ์ŠคํŠธ์—์„œ freelifemakers.com์—์„œ ๋‚ด ์„œ๋ฒ„(yourhost.domain.org)๋ฅผ ํŒ”๋กœ์šฐํ•˜์˜€์Šต๋‹ˆ๋‹ค.
In the previous post, I followed my server (yourhost.domain.org) from freelifemakers.com.

๐Ÿ‘‰๐Ÿป ํŒ”๋กœ์šฐ ํ•  ๋•Œ ์ €์žฅ๋œ ์ •๋ณด์— ๋”ฐ๋ผ ๋‚ด ์„œ๋ฒ„์—์„œ ๊ธ€์„ ์“ฐ๋ฉด freelifemakers.com์„œ๋ฒ„๋กœ ๊ธ€์„ ๋ฐฐ๋‹ฌํ•ฉ๋‹ˆ๋‹ค.
When you follow someone, posts written on your server are delivered to the freelifemakers.com server based on the stored information.

๐Ÿ‘‰๐Ÿป pinafore.social์—์„œ ๋ณด๋Š” ๋ชจ๋“  ์ •๋ณด๋Š” freelifemakers.com์„œ๋ฒ„์— ์ €์žฅ๋œ ์ •๋ณด๋ฅผ ๋ณด๊ฒŒ๋ฉ๋‹ˆ๋‹ค.
All information viewed on pinafore.social is retrieved from data stored on the freelifemakers.com server.

๐Ÿ’ก ํ…Œ์ŠคํŠธ๋Š” gotosocial์„œ๋ฒ„๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ…Œ์ŠคํŠธํ–ˆ์Šต๋‹ˆ๋‹ค.
The testing was conducted using a GoToSocial server.

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

myapp13/  
โ”œโ”€โ”€ app/  (Next.js App Router)
โ”‚   โ”œโ”€โ”€ .well-known/webfinger/route.ts     -> webfinger
โ”‚   โ”œโ”€โ”€ api/posts/route.ts     -> ๊ธ€ ์“ฐ๊ธฐ API / Writing API
โ”‚   โ”œโ”€โ”€ users/[username]/
โ”‚   โ”‚   โ”œโ”€โ”€ route.ts           -> Actor์ •๋ณด / Acotr Information
โ”‚   โ”‚   โ”œโ”€โ”€ inbox/route.ts     -> Inbox
โ”‚   โ”‚   โ””โ”€โ”€ outbox/route.ts    -> outbox
โ”‚   โ”œโ”€โ”€ layout.tsx, page.tsx, globals.css
โ”‚   โ””โ”€โ”€ favicon.ico
โ”œโ”€โ”€ lib/
โ”‚   โ”œโ”€โ”€ ap.ts                  -> Follow Accept
โ”‚   โ””โ”€โ”€ db.ts                  -> DB connection
โ”œโ”€โ”€ data/
โ”‚   โ””โ”€โ”€ keys/                  -> private.pem, public.pem
โ”œโ”€โ”€ data.sqlite                -> Database
โ”œโ”€โ”€ Caddyfile                  -> https 
โ””โ”€โ”€ package.json

๐Ÿ“ ํ”„๋กœ์ ํŠธ ์‹œ์ž‘(myapp13)
Project Start (myapp13)

npx create-next-app@latest

๐Ÿ“ SQLite์„ค์น˜ / Installing SQLite

cd ~/myapp13
npm install better-sqlite3
npm install -D @types/better-sqlite3

๐Ÿ“ ๋„๋ฉ”์ธ ํ—ˆ์šฉ / Allow Domain

— next.config.ts์— ์•„๋ž˜์ฒ˜๋Ÿผ ๋„๋ฉ”์ธ์„ ํ—ˆ์šฉํ•ฉ๋‹ˆ๋‹ค.
Allow the domain in next.config.ts as shown below.

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
allowedDevOrigins: ['host.yourdomain.org', '*.yourdomain.org'],
};

export default nextConfig;

๐Ÿ“ https์„ค์ • / HTTPS configuration

โœ”๏ธ Caddy ์„ค์น˜(MacOS) / Installing Caddy (macOS)

brew install caddy

โœ”๏ธ Caddy file ๋งŒ๋“ค๊ธฐ(myapp11 ํ”„๋กœ์ ํŠธ๋‚ด )
Create a Caddyfile (within the myapp11 project)

# ํŒŒ์ผ์—ด๊ธฐ / Open File
nano Caddyfile

# ์„ค์ • ์ž‘์„ฑ / Create Configuration
yourhost.domain.org {
    reverse_proxy localhost:3000
}

#ํŒŒ์ผ ์ €์ •ํ›„ ์ข…๋ฃŒ / Save file and exit (Ctrl + O,Ctrl + x)

โœ”๏ธ Caddy ์‹คํ–‰ / Run Caddy

caddy fmt --overwrite Caddyfile
caddy run

# background
caddy start
caddy stop

๐Ÿ“ ์ฝ”๋“œ ์ž‘์„ฑ / Writing code

โœ”๏ธ myapp13/app/users/[username]/route.ts

— public key ๊ฒ€์ฆ๊ธฐ๋Šฅ ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค.
This is the public key verification function.

— publick key ๊ฒ€์ฆ ์ž‘๋™ํ™•์ธ ํ•  ์ˆ˜ ์žˆ๊ณ  public key๊ฐ€ ์ธ์ฆ๋˜์ง€ ์•Š์•„๋„ ์˜ค๋ฅ˜๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
You can verify that public key validation is working, and it does not trigger an error even if the public key is not authenticated.

    // 1. Undo๋Š” ์ œ์ผ ๋จผ์ €! ๊ฒ€์ฆ ์—†์ด ์ฒ˜๋ฆฌํ•ด์•ผ ์–ธํŒ”๋กœ์šฐ๊ฐ€ ๋จ / Undo should be processed first without verification to allow unfollowing
    if (body.type === 'Undo') {
      const targetActor = typeof body.object?.actor === 'string' ? body.object.actor : body.object?.actor?.id || body.object?.id || body.actor;
      
       // Follow Undo์ธ ๊ฒฝ์šฐ / If it's a Follow Undo
      if (body.object?.type === 'Follow' || typeof body.object === 'string' || body.object?.id?.includes('#follow')) {
         const unfollowActorId = body.actor; // ๋ˆ„๊ฐ€ ์–ธํŒ”ํ–ˆ๋Š”์ง€ / who unfollowed
         db.prepare('DELETE FROM followers WHERE actor = ?').run(unfollowActorId);
         console.log(`๐Ÿ—‘๏ธ [${username}] ์–ธํŒ”๋กœ์šฐ / unfollow : ${unfollowActorId}`);
      }
      return new Response('', { status: 202 });
    }

    // 2. ๊ฒ€์ฆ - ํ…Œ์ŠคํŠธ๋ผ ์Šคํ‚ต, ๊ทผ๋ฐ publicKeyPem ์—†์„๋•Œ ํ„ฐ์ง€์ง€ ์•Š๊ฒŒ ๋ฐฉ์–ด / Verification - skipped for testing, but defend against missing publicKeyPem
    try {
      const actorUrl = body.actor;
      const actorData = await fetch(actorUrl, {
        headers: { Accept: 'application/activity+json' }
      }).then(r => r.json());
      
      // optional chaining์œผ๋กœ ๋ฐฉ์–ด / Defend with optional chaining
      const publicKeyPem = actorData?.publicKey?.publicKeyPem;
      
      if (!publicKeyPem) {
        console.log(`โš ๏ธ [${username}] publicKey ์—†์Œ, ๊ฒ€์ฆ ์Šคํ‚ต / no publicKey, skip verify`);
      } else {
        // const isValid = verify(req, publicKeyPem); 
        // if (!isValid) return Response.json({}, { status: 401 });
      }
    } catch (verErr) {
      console.log(`โš ๏ธ [${username}] actor fetch ์‹คํŒจ, ๊ฒ€์ฆ ์Šคํ‚ต / fetch failed, skip verify`, verErr);
    }

โœ”๏ธ myapp13/app/api/posts/route.ts

— ๊ธ€ ๋ฐฐ๋‹ฌํ•˜๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.
This is a feature for delivering posts.

— ๊ธฐ์กด์ฝ”๋“œ์—์„œ POSTํ•จ์ˆ˜ ๋ถ€๋ถ„๋งŒ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
Only the POST function section of the existing code has been modified.

// myapp13 โœ…
export async function POST(req: Request) {
  const { content, username = 'user1' } = await req.json();
  if (!content) return Response.json({ error: '๋‚ด์šฉ ์—†์Œ / Content is required' }, { status: 400 });

  const id = randomUUID();
  db.prepare('INSERT INTO posts (id, content) VALUES (?, ?)').run(id, content);

    // 1. ActivityPub Note ๋งŒ๋“ค๊ธฐ / Create ActivityPub Note
  const noteId = `https://aloy-horizon.duckdns.org/users/${username}/posts/${id}`;
  const note = {
    id: noteId,
    type: 'Note',
    attributedTo: `https://aloy-horizon.duckdns.org/users/${username}`,
    content: content,
    to: ['https://www.w3.org/ns/activitystreams#Public'],
    cc: [`https://aloy-horizon.duckdns.org/users/${username}/followers`]
  };

  // 2. ํŒ”๋กœ์›Œ๋“คํ•œํ…Œ ๋ฐฐ๋‹ฌ! / Deliver to followers!
  const followers = db.prepare('SELECT * FROM followers').all() as any[];
  console.log(`๐Ÿ“ค ${followers.length}๋ช…์—๊ฒŒ ๋ฐฐ๋‹ฌ ์‹œ์ž‘ / delivering to ${followers.length} followers`);

  for (const follower of followers) {
    try {
      await sendNote(follower.inbox, note, username, id, content);
      console.log(`โœ… ๋ฐฐ๋‹ฌ ์„ฑ๊ณต / Delivery successful -> ${follower.actor}`);
    } catch (e) {
      console.error(`โŒ ๋ฐฐ๋‹ฌ ์‹คํŒจ / Delivery failed -> ${follower.actor}`, e);
    }
  }

  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
  return Response.json(post);
}

โœ”๏ธ myapp13/lib/ap.ts

— ๊ธฐ์กด ์ฝ”๋“œ์—์„œ sendNoteํ•จ์ˆ˜๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
The sendNote function has been added to the existing code.

export async function sendNote(toInbox: string, note: any, username: string, postId: string, content: string) {
  const actorId = `https://${DOMAIN}/users/${username}`;
  
  const createDoc = {
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${actorId}/posts/${postId}#create`,
    type: 'Create',
    actor: actorId,
    object: note
  };

  const body = JSON.stringify(createDoc);
  const url = new URL(toInbox);
  
  const digest = `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}`;
  const date = new Date().toUTCString();
  const signingString = `(request-target): post ${url.pathname}\nhost: ${url.host}\ndate: ${date}\ndigest: ${digest}`;
  
  const signer = crypto.createSign('sha256');
  signer.update(signingString);
  const signature = signer.sign(PRIVATE_KEY, 'base64');

  const keyId = `${actorId}#main-key`;
  const sigHeader = `keyId="${keyId}",headers="(request-target) host date digest",signature="${signature}"`;

  console.log(`๐Ÿ“ [${username}] Note ์ „์†ก / Note sent -> ${toInbox} : ${content.slice(0,20)}`);

  const res = await fetch(toInbox, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/activity+json',
      'Date': date,
      'Digest': digest,
      'Signature': sigHeader,
      'Host': url.host
    },
    body
  });

  const text = await res.text();
  console.log(`๐Ÿ“ฌ Note ๊ฒฐ๊ณผ / Note result: ${res.status}`, text);
  return res.ok;
}

๐Ÿ“ ๊ธ€ ๋ฐฐ๋‹ฌ ํ…Œ์ŠคํŠธ / Text Delivery Test

โœ”๏ธ ๊ธ€์“ฐ๊ธฐ(ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰) / Writing (run in the terminal)

# 1. ๊ธ€์“ฐ๊ธฐ(localhost ๋ง๊ณ  https ๋„๋ฉ”์ธ์œผ๋กœ ๋ณด๋‚ด๊ธฐ)
Writing (Send via HTTPS domain instead of localhost)

curl -X POST https://aloy-horizon.duckdns.org/api/posts \
  -H "Content-Type: application/json" \
  -d '{"content":"Hello! my SNS!!", "username":"user1"}'

# 2. outbox์— ๋œจ๋Š”์ง€ ํ™•์ธ!
Check if it appears in the outbox!

curl https://aloy-horizon.duckdns.org/users/user1/outbox \
  -H "Accept: application/activity+json" | jq

# 3. ํŒ”๋กœ์›Œ ์žˆ๋Š”์ง€ ํ™•์ธ!
Check if you have any followers!

sqlite3 data.sqlite "SELECT * FROM followers;"

โœ”๏ธ ๊ธ€ ๋ฐฐ๋‹ฌ์ด ๋˜๋Š”์ง€ ํ™•์ธ / Check if the message is being delivered.

— freelifemakers.com์—์„œ ๋‚ด ์„œ๋ฒ„์— ํŒ”๋กœ์šฐ ๋˜์–ด ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
You must be following my server on freelifemakers.com.

๐Ÿ“ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด ํŒ”๋กœ์›Œ๋ฅผ ๋Š์—ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ํŒ”๋กœ์›Œ ํ•˜๋ ค๋ฉด ์•„๋ž˜์ฒ˜๋Ÿผ ํ•ฉ๋‹ˆ๋‹ค.
To unfollow and then re-follow for testing purposes, proceed as follows.

sqlite3 data.sqlite "DELETE FROM followers;"

# 3. ๋‹ค์‹œ aloy -> freelifemakers ํŒ”๋กœ์šฐ
# Following aloy -> freelifemakers again.
curl -X POST https://aloy-horizon.duckdns.org/api/follow \
  -H "Content-Type: application/json" \
  -d '{
    "username": "user1",
    "target": "https://freelifemakers.com/users/user1"
  }'

๐Ÿ“ gotosocial์—์„œ ํŒ”๋กœ์›Œ๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜๋Š” ๋ฐฉ๋ฒ•์€ ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.
Here is how to reset followers in GoToSocial.

-- follows ์ง€์šฐ๊ธฐ / Clear follows
DELETE FROM follows;

-- stats 0์œผ๋กœ ์…‹ํŒ… / Set stats to 0.
UPDATE account_stats SET followers_count=0, following_count=0, statuses_count=0;

์š”ํ•œ๋ณต์Œ 8์žฅ 32์ ˆ / John 8:32

“๊ทธ๋ฆฌ๊ณ  ๋„ˆํฌ๋Š” ์ง„๋ฆฌ๋ฅผ ์•Œ๊ฒŒ ๋  ๊ฒƒ์ด๋ฉฐ, ์ง„๋ฆฌ๊ฐ€ ๋„ˆํฌ๋ฅผ ์ž์œ ๋กญ๊ฒŒ ํ•  ๊ฒƒ์ด๋‹ค.”

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

Leave a Reply