ππ» μ΄λ² ν¬μ€νΈλ νλ‘μ 리μ€νΈλ₯Ό ꡬννλ λΆλΆμ
λλ€.
This post covers the implementation of the following list feature.
ππ» νλ‘μκ³Ό νλ‘μ° κ°λ
μ μλμ κ°μ΅λλ€.
The concepts of “following” and “being followed” are as follows.
1. followers
- λ¨(user1@freelifemakers.com)μ΄ λλ₯Ό νλ‘μ°νλ κ²½μ° μ
λλ€.
This is the case where someone (user1@freelifemakers.com) follows me.
- λ€μκ³Ό κ°μ κ³Όμ μ΄ μνλλ κ²½μ°μ
λλ€.
This applies when the following process is carried out.
1)pinafore.socialμ λ‘κ·ΈμΈ(user1@freelifemakers.com)
Log in to pinafore.social (user1@freelifemakers.com)
2)user1@aloy-horizon.duckdns.orgμ κ²μ
Search for user1@aloy-horizon.duckdns.org
3)νλ‘μ° λ²νΌ ν΄λ¦νκΈ°
Click the Follow button.
4)λμ μλ² inboxλ‘ Follow λ€μ΄μ€κ³ followers ν
μ΄λΈμ μ μ₯
A "follow" request arrives in my server's inbox and is stored in the `followers` table.
2. following
- λ΄(user1@aloy-horizon.duckdns.org)κ° λ¨(user1@freelifemakers.com)μ νλ‘μ°νλ κ²½μ° μ
λλ€.
This is the case where I (user1@aloy-horizon.duckdns.org) follow another person (user1@freelifemakers.com).
- μ΄κ±΄ pinaforeμμ ν
μ€νΈ λμ§ μμ΅λλ€.
This is not tested in Pinafore.
- λ΄ μλ²κ° user1@freelifemakers.comκ°μ μΈλΆ μ¬λμ νλ‘μ°νλ κ²½μ° μ
λλ€.
This is the case where my server follows an external user, such as user1@freelifemakers.com.
- λ΄κ°νλ‘μ° νλ μ¬λμ following ν
μ΄λΈμ μ μ₯ λ©λλ€.
The people I follow are stored in the 'following' table.
π νλ‘μ νΈ μ€μΉ,λΌμ΄λΈλ¬λ¦¬ μ€μΉ,HTTPSμ€μ
Project setup, library installation, HTTPS configuration
βοΈ μλμ μ΄μ ν¬μ€νΈλ₯Ό μ°Έμ‘°νμΈμ
Please refer to the previous post below.
π μ 체 νλ‘μ νΈ κ΅¬μ‘° / Overall Project Structure
myapp16/
βββ app/ (Next.js App Router)
β βββ .well-known/webfinger/route.ts -> webfinger
β βββ api/posts/route.ts -> κΈ μ°κΈ° API / Writing API
β βββ api/follow/route.ts -> νλ‘μ° API(μμ) / Follow API(temporary)
β βββ users/[username]/
β β βββ route.ts -> Actorμ 보 / Acotr Information
β β βββ followers/route.ts -> Followers List
β β βββ folloing/route.ts -> Following List
β β βββ 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
π νλ‘μ νΈ μμ(myapp17)
Project Start (myapp17)
npx create-next-app@latest
π SQLiteμ€μΉ / Installing SQLite
cd ~/myapp17
npm install better-sqlite3
npm install -D @types/better-sqlite3
π following ν μ΄λΈ λ° μΈλ±μ€ μμ±
CREATE TABLE following (
id TEXT PRIMARY KEY,
actor TEXT NOT NULL,
username TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_following_username ON following(username);
CREATE INDEX idx_following_actor ON following(actor);
π μ½λ μμ λ° λΌμ°νΈ μΆκ° / Code modifications and route additions
βοΈ λΌμ°νΈ μΆκ° / Add Route
— app/users/[username]/following/route.ts
import db from '@/lib/db';
export async function GET(
req: Request,
{ params }: { params: Promise<{ username: string }> }
) {
const { username } = await params; // β await ν΄μΌν¨ / must await
const DOMAIN = process.env.DOMAIN || 'aloy-horizon.duckdns.org';
let following: any[] = [];
try {
following = db.prepare('SELECT actor FROM following WHERE username = ?').all(username) as any[];
} catch (e) {
console.log('following ν
μ΄λΈ μμ λλ μλ¬ / Following table not found or error:', e);
}
return Response.json({
"@context": "https://www.w3.org/ns/activitystreams",
"id": `https://${DOMAIN}/users/${username}/following`,
"type": "OrderedCollection",
"totalItems": following.length,
"orderedItems": following.map((f: any) => f.actor)
}, {
headers: {
'Content-Type': 'application/activity+json; charset=utf-8',
'Access-Control-Allow-Origin': '*'
}
});
}
1) λ΄κ° νλ‘μ°ν μ¬μ©μ 리μ€νΈλ₯Ό μΆλ ₯ν©λλ€.
Displays the list of users I follow.
βοΈ μ½λ μμ / Code modification
— app/api/follow/route.ts
import { sendFollow, signedFetch } from '@/lib/ap';
import db from '@/lib/db';
// νλ‘μ° API / Follow API
export async function POST(req: Request) {
... ...
// myapp17β
// 4.μ±κ³΅νλ©΄ following ν
μ΄λΈμ μ μ₯! / If successful, save to the following table!
if (result.ok) {
try {
db.prepare('INSERT OR IGNORE INTO following (id, actor, username) VALUES (?, ?, ?)')
.run(result.followDoc.id, target, username);
console.log(`β
following DB μ μ₯ / If successful, save to the following table!: ${username} -> ${target}`);
} catch (e) {
console.error('DB μ μ₯ μ€ν¨ / DB save failed:', e);
}
}
... ...
}
// μΈνλ‘μ° μΆκ° / Unfollow added
export async function DELETE(req: Request) {
try {
const { username, target } = await req.json();
if (!username || !target) return Response.json({ error: 'username, target νμ / Username and target required' }, { status: 400 });
const DOMAIN = process.env.DOMAIN || 'aloy-horizon.duckdns.org';
// Undo Follow λ§λ€κΈ° (sendFollow μ°Έκ³ ν΄μ) / Create Undo Follow (refer to sendFollow)
const { sendUndoFollow } = await import('@/lib/ap');
const result = await sendUndoFollow(target, username);
// DBμμ μμ / Delete from DB
db.prepare('DELETE FROM following WHERE actor = ? AND username = ?').run(target, username);
console.log(`ποΈ following μμ / Delete from DB: ${username} -X-> ${target}`);
return Response.json({ ok: true, result });
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500 });
}
}
1)νλ‘μ°,μΈνλ‘μ° ν λ followingν
μ΄λΈμμ μ¬μ©μλ₯Ό μΆκ°νκ³ μμ ν©λλ€.
Users are added to and removed from the following table when following or unfollowing.
— lib/ap.ts
port async function sendUndoFollow(targetActor: string, username: string) {
const actorId = `https://${DOMAIN}/users/${username}`;
// letμΌλ‘ λ°μ μ μΈ(νμ
μλ¬ μμ ) / Declare outside with let (type error fix)
let followId = `${actorId}/follows/${targetActor}`;
try {
const db = (await import('@/lib/db')).default;
const row = db.prepare('SELECT id FROM following WHERE actor = ? AND username = ?').get(targetActor, username) as any;
if (row?.id) {
followId = row.id;
}
} catch {}
const actorRes = await signedFetch(targetActor, username);
if (!actorRes.ok) {
const t = await actorRes.text();
throw new Error(`λμ μ‘°ν μ€ν¨ ${actorRes.status}: ${t}`);
}
const actorData = await actorRes.json();
const inbox = actorData.inbox;
const undoId = `${actorId}#undo/${Date.now()}`;
const undoDoc = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: undoId,
type: 'Undo',
actor: actorId,
object: {
id: followId,
type: 'Follow',
actor: actorId,
object: targetActor
}
};
const body = JSON.stringify(undoDoc);
const url = new URL(inbox);
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}] Undo Follow μ μ‘ -> ${inbox}`);
const res = await fetch(inbox, {
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(`π¬ Undo κ²°κ³Ό / Undo result: ${res.status}`, text);
return { ok: res.ok, status: res.status, text, undoDoc };
}
1)app/api/follow/route.tsμμ νΈμΆλλ νλ‘μ° μ·¨μ ν¨μμ
λλ€.
This is the unfollow function called from app/api/follow/route.ts.
π ν μ€νΈ νκΈ° / Run a test
βοΈ λΈλΌμ°μ μμ folllowing 리μ€νΈ 보기
View the following list in the browser.
https://aloy-horizon.duckdns.org/users/user1/following

βοΈ ν°λ―Έλμμ νλ‘μ°,μΈνλ‘μ° ,νλ‘μ 리μ€νΈλ³΄κΈ°
Follow, unfollow, and view following list in the terminal.
# 1. user1μ΄ freelifemakersλ₯Ό νλ‘μ° (followingμ μ μ₯)
# user1 follows freelifemakers (saved to 'following')
curl -X POST https://aloy-horizon.duckdns.org/api/follow \
-H "Content-Type: application/json" \
-d '{"username":"user1", "target":"https://freelifemakers.com/users/user1"}'
# νμΈ / check
curl https://aloy-horizon.duckdns.org/users/user1/following
# β totalItems: 1
# 2. μΈνλ‘μ° (followingμμ μμ )
# Unfollow (remove from 'following' list)
curl -X DELETE https://aloy-horizon.duckdns.org/api/follow \
-H "Content-Type: application/json" \
-d '{"username":"user1", "target":"https://freelifemakers.com/users/user1"}'
# νμΈ / check
curl https://aloy-horizon.duckdns.org/users/user1/following
# β totalItems: 0
βοΈ ν°λ―Έλμμ μ€νκ²°κ³Ό (νλ‘μ°)
Execution results in the terminal (follow)
following % curl -X POST https://aloy-horizon.duckdns.org/api/follow \
-H "Content-Type: application/json" \
-d '{"username":"user1", "target":"https://freelifemakers.com/users/user1"}'
{"ok":true,"inbox":"https://freelifemakers.com/users/user1/inbox","target":"https://freelifemakers.com/users/user1","username":"user1","result":"{\"status\":\"Accepted\"}","follow":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1/follows/1787190758589","type":"Follow","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://freelifemakers.com/users/user1"}}%
following %
π μ§κΈκΉμ§ μλ£ν λ΄μ© / Work completed so far
— WebFinger (users ν
μ΄λΈ μμ± + DB κ²μ)
WebFinger (create ‘users’ table + DB search)
— Follow 보λ΄κΈ° / λ°κΈ°
Follow Send/Receive
— κΈ λ°°λ¬
Article(Post) Delivery
— λ΄κ° νλ‘μ°ν 리μ€νΈ λ§λ€κΈ°
Create a list of people I follow
— λΈλΌμ°μ μμ ν
μ€νΈ ν μ μλ λΌμ°νΈ
Routes that can be tested in a browser
1. WebFinger
https://aloy-horizon.duckdns.org/.well-known/webfinger?resource=acct:user1@aloy-horizon.duckdns.org
2. Actor (νλ‘ν / Profile)
https://aloy-horizon.duckdns.org/users/user1
3. Followers (λ¨μ΄ λ νλ‘μ° / Someone follows me)
https://aloy-horizon.duckdns.org/users/user1/followers
4. Following (λ΄κ° λ¨μ νλ‘μ° / I follow others)
https://aloy-horizon.duckdns.org/users/user1/following
5. Outbox (λ΄ κΈ / My Post)
https://aloy-horizon.duckdns.org/users/user1/outbox
6. κ°λ³ κΈ / indivisual posts
myapp18μμ ꡬν μμ
Scheduled for implementation in myapp18.
— ν°λ―Έλμμλ§ ν
μ€νΈ κ°λ₯ν λΌμ°νΈ
Routes that can only be tested in the terminal
# νλ‘μ° (following 1λ‘ μ¦κ°)
# Follow (following increased to 1)
curl -X POST https://aloy-horizon.duckdns.org/api/follow \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/users/user1"}'
# μΈνλ‘μ° (following 0μΌλ‘ κ°μ)
# Unfollow (following count reduced to 0)
curl -X DELETE https://aloy-horizon.duckdns.org/api/follow \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/users/user1"}'
# κΈ μ°κΈ° (outbox 7λ‘ μ¦κ° + νλ‘μλ€μκ² λ°°λ¬)
# Writing a post (added to outbox 7 + delivered to followers)
curl -X POST https://aloy-horizon.duckdns.org/api/posts \
-H "Content-Type: application/json" \
-d '{"username":"user1","content":"Hello Fediverse! #test"}'
1)λΈλΌμ°μ μμ μ¬μ©μ POSTμ μ‘μ΄λ DELETEμ μ‘μ νΌ submitλ±μ νμμΌλ‘ μ μ‘λμ΄μΌ ν©λλ€.
When using a browser, POST or DELETE requests must be sent using a format such as a “form submit.”
ππ» μ¬κΈ°μλ λ΄κ° μ΄κΈμ postsν
μ΄λΈμ μ μ₯νκ³ λ΄κ° νλ‘μνλ μλ²μμ λ°μ κΈμ inbox_postsμ μ μ₯λλλ‘ μμ νλ λΆλΆμ
λλ€.
Here, we are modifying the system so that posts I write are stored in the posts table, while posts received from servers I follow are stored in inbox_posts.
ππ» postν
μ΄λΈμ μΈλΆ κΈμ΄ μμ΄λ λ²κ·Έλ₯Ό μμ ν λΆλΆμ
λλ€.
This change fixes a bug where external posts were getting mixed into the post table.
π DB λ§μ΄κ·Έλ μ΄μ / DB Migration
sqlite3 data.db
# postsμ username μ»¬λΌ μΆκ°
# Add username column to posts
ALTER TABLE posts ADD COLUMN username TEXT DEFAULT 'user1';
# μλͺ» λ€μ΄κ° remote κΈ μμ
# Deleted accidentally posted remote-related entry
DELETE FROM posts WHERE id LIKE 'https://%';
# inbox_posts ν
μ΄λΈ λ§λ€κΈ°
# Create the inbox_posts table
CREATE TABLE IF NOT EXISTS inbox_posts (
id TEXT PRIMARY KEY,
actor TEXT,
content TEXT,
username TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
π μ½λ μμ / Code Modification
βοΈ api/posts/route.ts
// app/api/posts/route.ts
// β
myapp17
import db from '@/lib/db';
import { randomUUID } from 'crypto';
import { sendNote } from '@/lib/ap';
const DOMAIN = process.env.DOMAIN || 'aloy-horizon.duckdns.org';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const username = searchParams.get('username') || 'user1';
// β
ν΄λΉ μ μ κΈλ§ / Only that user's posts
const posts = db.prepare('SELECT * FROM posts WHERE username = ? ORDER BY created_at DESC').all(username);
return Response.json(posts);
}
export async function POST(req: Request) {
const { content, username = 'user1' } = await req.json();
if (!content) return Response.json({ error: 'λ΄μ© μμ / Content required' }, { status: 400 });
const id = randomUUID();
// β
username μ μ₯! / Save username!
db.prepare('INSERT INTO posts (id, content, username) VALUES (?, ?, ?)').run(id, content, username);
const noteId = `https://${DOMAIN}/users/${username}/posts/${id}`;
const note = {
id: noteId,
type: 'Note',
attributedTo: `https://${DOMAIN}/users/${username}`,
content: content,
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: [`https://${DOMAIN}/users/${username}/followers`]
};
// β
ν΄λΉ μ μ μ νλ‘μλ§!
const followers = db.prepare('SELECT * FROM followers WHERE username = ?').all(username) as any[];
console.log(`π€ [${username}] ${followers.length}λͺ
μκ² λ°°λ¬`);
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);
}
export async function PUT(req: Request) {
const { id, content, username = 'user1' } = await req.json();
if (!id || !content) {
return Response.json({ error: 'idμ content νμ' }, { status: 400 });
}
// β
λ΄ κΈλ§ μμ / Only edit my own posts
const result = db.prepare('UPDATE posts SET content = ? WHERE id = ? AND username = ?').run(content, id, username);
if (result.changes === 0) {
return Response.json({ error: 'ν΄λΉ κΈ μμ / Post not found' }, { status: 404 });
}
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
return Response.json(post);
}
export async function DELETE(req: Request) {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
const username = searchParams.get('username') || 'user1';
if (!id) return Response.json({ error: 'id νμ / id required' }, { status: 400 });
// β
λ΄ κΈλ§ μμ / Only delete my own posts
db.prepare('DELETE FROM posts WHERE id = ? AND username = ?').run(id, username);
return Response.json({ ok: true });
}
βοΈ app/inbox/route.ts
if (body.type === 'Create') {
const note = body.object;
if (note && note.type === 'Note') {
console.log(`π [${username}] μ κΈ λμ°© / New post received from ${actorId}`);
console.log(`λ΄μ©/content: ${note.content?.slice(0, 100)}`);
try {
const postId = note.id || `remote-${Date.now()}-${Math.random()}`;
const content = note.content || '';
// author μ»¬λΌ μμΌλ©΄ μλ¬λλκΉ contentμ μμ±μ ν¬ν¨ν΄μ μ μ₯
// If there's no author column, include the author in the content to avoid errors
const fullContent = `[from: ${actorId}] ${content}`;
// db.prepare('INSERT OR IGNORE INTO posts (id, content) VALUES (?,?)')
// .run(postId, fullContent);
//console.log(` [${username}] κΈ μ μ₯ μλ£ / Post saved successfully : ${postId}`);
// β
myapp17 - inbox_postsμ μ μ₯! posts μλ! / Save to inbox_posts, not posts!
db.prepare(`
INSERT OR IGNORE INTO inbox_posts (id, actor, content, username)
VALUES (?, ?, ?, ?)
`).run(postId, actorId, content, username);
console.log(`β
[${username}] inbox_posts μ μ₯ μλ£ : ${postId}`);
} catch (e) {
console.error(`β κΈ μ μ₯ μ€ν¨ / Failed to save post`, e);
}
}
return new Response('', { status: 202 });
}
π ν μ€νΈ / Test
βοΈ λ΄ μλ²μ κΈ μ°κΈ° / write post to my server
# κΈ μ°κΈ° (outbox 7λ‘ μ¦κ° + νλ‘μλ€μκ² λ°°λ¬)
# Writing a post (added to outbox 7 + delivered to followers)
curl -X POST https://aloy-horizon.duckdns.org/api/posts \
-H "Content-Type: application/json" \
-d '{"username":"user1","content":"Hello Fediverse! #test"}'
myapp17 % sqlite3 data.sqlite
SQLite version 3.51.0 2025-06-12 13:14:41
Enter ".help" for usage hints.
sqlite> select * from posts;
e6c99652-572f-491a-9e61-05e67b0d58fc|Hello! my SNS!!|2026-08-12 04:59:51|user1
dcece9ee-8d4a-4f63-91e1-22fc60513f62|Hello! my SNS!!|2026-08-12 05:00:18|user1
cc30330e-7040-4a1b-9e60-731066b8817e|Hello! my SNS3!!|2026-08-12 23:08:05|user1
e6d532c8-974b-4a01-89f6-6e1b2c4cff32|Hello Fediverse! #test|2026-08-22 02:16:04|user1
aea0ec89-a368-44a9-8493-b2bd82a797a8|post test! #test|2026-08-12 03:08:18|user1
sqlite> select * from inbox_posts;
sqlite>
βοΈ κΈλ°κΈ°(inbox_posts ν
μ΄λΈμ μ μ₯)
Receive posts (stored in the inbox_posts table)
— κΈ μμ±μ pinafore(website)λ Tusky(android),Feditex(IOS)μμ μμ±ν μ μμ΅λλ€.
You can create posts using Pinafore(website), Tusky (Android), or Feditex (iOS).
— μλ² λ‘κ·Έ / Server Log
# server log
π© [user1] INBOX: Create https://freelifemakers.com/users/user1
π [user1] μ κΈ λμ°© / New post received from https://freelifemakers.com/users/user1
λ΄μ©/content: <p>inbox_posts table test</p>
β
[user1] inbox_posts μ μ₯ μλ£ : https://freelifemakers.com/users/user1/statuses/01M0EJPP57WC47FAK0N395S7MB
— index_postsμ κΈ μ μ₯νμΈ νκΈ°
Check if posts are saved in index_posts
sqlite> select * from inbox_posts;
https://freelifemakers.com/users/user1/statuses/0--------7FAK0N395----|https://freelifemakers.com/users/user1|<p>inbox_posts table test</p>|user1|2026-08-20 03:16:15
sqlite>

μν볡μ 8μ₯ 32μ / John 8:32
“κ·Έλ¦¬κ³ λν¬λ μ§λ¦¬λ₯Ό μκ² λ κ²μ΄λ©°, μ§λ¦¬κ° λν¬λ₯Ό μμ λ‘κ² ν κ²μ΄λ€.”
“Then you will know the truth ,and the truth will set you free”