๐๐ป 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”