👉🏻 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.
📁 전체 프로젝트 구조 / Project Structure
myapp12/
├── app/ (Next.js App Router)
│ ├── 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.