ππ»μ’μμμ μ’μμ μ·¨μκΈ°λ₯μ ꡬνν©λλ€.
Implement the “like” and “unlike” functions.
π μ 체 νλ‘μ νΈ κ΅¬μ‘° / Overall Project Structure
myapp23/
βββ app/ (Next.js App Router)
β βββ .well-known/webfinger/route.ts -> webfinger
β βββ api/posts/route.ts -> Writing API
β βββ api/follow/route.ts -> Follow API(temporary)
β βββ api/timeline/route.ts -> Timeline API
β βββ api/like/route.ts. -> Like API
β βββ 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
β βββ 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
π νλ‘μ νΈ μμ(myapp23)
Project Start (myapp23)
npx create-next-app@latest
π SQLiteμ€μΉ / Installing SQLite
cd ~/myapp23
npm install better-sqlite3
npm install -D @types/better-sqlite3
π DDNS,httpsμ€μ / DDNS,https settings
π λλ©μΈνμ©, @userid μ¬μ©νκΈ°
Allowing domains, using @userid
— next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* allow domain*/
allowedDevOrigins: ['aloy-horizon.duckdns.org', '*.duckdns.org'],
/* host.domain.org/@userid */
async rewrites() {
return [
{
source: '/@:username',
destination: '/usersui/:username',
},
]
}
};
export default nextConfig;
π ν μ΄λΈ μΆκ° / Add table
βοΈ ν
μ΄λΈ λ° μΈλ±μ€ μμ±(sqlite)
Create Table and Index (sqlite)
CREATE TABLE IF NOT EXISTS likes (
id TEXT PRIMARY KEY,
actor TEXT NOT NULL,
object TEXT NOT NULL,
username TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_likes_object ON likes(object);
CREATE INDEX IF NOT EXISTS idx_likes_actor ON likes(actor);
1)id TEXT PRIMARY KEY,
— Like Activity ID: https://aloy-horizon.duckdns.org/users/user1/likes/1234
2)actor TEXT NOT NULL,
— μ’μμ λλ₯Έμ¬λ / People who liked this
— https://freelifemakers.com/users/user1
3)object TEXT NOT NULL,
— μ’μμ λλ₯Έ κΈ / Posts I’ve liked
— https://aloy-horizon.duckdns.org/users/user1/statuses/abc
4) username TEXT NOT NULL,
— λ΄ λ‘컬 μ μ /My local user:: user1
βοΈ myapp23/lib/db.ts μ½λ μμ
import Database from 'better-sqlite3';
import path from 'path';
const dbPath = path.join(process.cwd(), 'data.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec(`
... ...
-- myapp23 β
likes/Undo likes
CREATE TABLE IF NOT EXISTS likes (
id TEXT PRIMARY KEY,
actor TEXT NOT NULL,
object TEXT NOT NULL,
username TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_followers_username ON followers(username);
CREATE INDEX IF NOT EXISTS idx_followers_actor ON followers(actor);
CREATE INDEX IF NOT EXISTS idx_following_username ON following(username);
CREATE INDEX IF NOT EXISTS idx_following_actor ON following(actor);
CREATE INDEX IF NOT EXISTS idx_posts_username_created ON posts(username, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_inbox_username_created ON inbox_posts(username, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_inbox_actor ON inbox_posts(actor);
CREATE UNIQUE INDEX IF NOT EXISTS idx_following_actor_username ON following(actor, username);
CREATE UNIQUE INDEX IF NOT EXISTS idx_followers_actor_username ON followers(actor, username);
-- myapp23 β
CREATE INDEX IF NOT EXISTS idx_likes_object ON likes(object);
CREATE INDEX IF NOT EXISTS idx_likes_actor ON likes(actor);
`);
export default db;
π μ½λ μμ λ° λΌμ°νΈ μΆκ°
Code modifications and route additions
βοΈ λΌμ°νΈ μΆκ° / Add Route
— app/api/like/route.ts
// app/api/like/route.ts
// myapp23 β
- Like / Undo Like
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
import { getActorData, sendLike, sendUndoLike, signedFetch } from '@/lib/ap';
export async function POST(req: NextRequest) {
try {
const { username, target } = await req.json();
if (!username || !target) {
return NextResponse.json({ ok: false, error: 'username and target required' }, { status: 400 });
}
let inbox: string;
let apObjectId: string = target;
try {
const postRes = await signedFetch(target, username);
if (postRes.ok) {
const postData = await postRes.json();
apObjectId = postData.id || target;
const attributedTo = postData.attributedTo || postData.actor;
const actorUrl = typeof attributedTo === 'string' ? attributedTo : attributedTo?.id;
if (!actorUrl) throw new Error('no actor in post');
const actorInfo = await getActorData(actorUrl, username);
inbox = actorInfo.inbox;
} else {
const url = new URL(target);
const parts = url.pathname.split('/');
const usersIdx = parts.indexOf('users');
if (usersIdx === -1) throw new Error('invalid target');
const actorUrl = `${url.origin}${parts.slice(0, usersIdx + 2).join('/')}`;
const actorInfo = await getActorData(actorUrl, username);
inbox = actorInfo.inbox;
}
} catch (e) {
console.error(e);
return NextResponse.json({ ok: false, error: 'invalid target post id' }, { status: 400 });
}
// μ€λ³΅ 체ν¬λ apObjectIdλ‘ ν΄μΌ ν¨ (Mastodon λμ)
const existing = db.prepare('SELECT id FROM likes WHERE object = ? AND username = ?').get(apObjectId, username) as any;
if (existing) {
return NextResponse.json({ ok: true, alreadyLiked: true, id: existing.id });
}
const result = await sendLike(inbox, apObjectId, username);
db.prepare('INSERT OR IGNORE INTO likes (id, actor, object, username) VALUES (?,?,?,?)')
.run(result.likeDoc.id, result.likeDoc.actor, result.likeDoc.object, username);
return NextResponse.json({ ok: true, result });
} catch (e: any) {
console.error('[Like POST]', e);
return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
try {
const { username, target } = await req.json();
if (!username || !target) {
return NextResponse.json({ ok: false, error: 'username and target required' }, { status: 400 });
}
let apObjectId: string = target;
let inbox: string;
try {
const postRes = await signedFetch(target, username);
if (postRes.ok) {
const postData = await postRes.json();
apObjectId = postData.id || target;
const attributedTo = postData.attributedTo || postData.actor;
const actorUrl = typeof attributedTo === 'string' ? attributedTo : attributedTo?.id;
if (!actorUrl) throw new Error('no actor');
const actorInfo = await getActorData(actorUrl, username);
inbox = actorInfo.inbox;
} else {
const url = new URL(target);
const parts = url.pathname.split('/');
const usersIdx = parts.indexOf('users');
if (usersIdx === -1) throw new Error('invalid target');
const actorUrl = `${url.origin}${parts.slice(0, usersIdx + 2).join('/')}`;
const actorInfo = await getActorData(actorUrl, username);
inbox = actorInfo.inbox;
}
} catch (e) {
return NextResponse.json({ ok: false, error: 'invalid target post id' }, { status: 400 });
}
const likeRow = db.prepare('SELECT id FROM likes WHERE object = ? AND username = ?').get(apObjectId, username) as any;
if (!likeRow) {
return NextResponse.json({ ok: true, alreadyUnliked: true });
}
const result = await sendUndoLike(inbox, likeRow.id, apObjectId, username);
db.prepare('DELETE FROM likes WHERE id = ?').run(likeRow.id);
return NextResponse.json({ ok: true, result });
} catch (e: any) {
console.error('[Like DELETE]', e);
return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
}
}
βοΈ μ½λμμ / Code modification
— lib/ap.tsμ sendLike, sendUndoLike μΆκ°
... ...
// β
myapp23 - Like 보λ΄κΈ° / Send Like
export async function sendLike(toInbox: string, targetPostId: string, username: string) {
const actorId = `https://${DOMAIN}/users/${username}`;
const likeId = `${actorId}/likes/${Date.now()}`;
const likeDoc = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: likeId,
type: 'Like',
actor: actorId,
object: targetPostId
};
const body = JSON.stringify(likeDoc);
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}] Like μ μ‘ -> ${toInbox} (${targetPostId})`);
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(`π¬ Like κ²°κ³Ό: ${res.status}`, text);
return { ok: res.ok, status: res.status, text, likeDoc };
}
// β
myapp23 - Undo Like 보λ΄κΈ° / Send Undo Like
export async function sendUndoLike(toInbox: string, likeId: string, targetPostId: string, username: string) {
const actorId = `https://${DOMAIN}/users/${username}`;
const undoId = `${actorId}#undo/${Date.now()}`;
const undoDoc = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: undoId,
type: 'Undo',
actor: actorId,
object: {
id: likeId,
type: 'Like',
actor: actorId,
object: targetPostId
}
};
const body = JSON.stringify(undoDoc);
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}] Undo Like μ μ‘ -> ${toInbox}`);
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(`π¬ Undo Like κ²°κ³Ό: ${res.status}`, text);
return { ok: res.ok, status: res.status, text, undoDoc };
}
// β
myapp23 - Actor λ°μ΄ν° κ°μ Έμ€κΈ° (inbox/route, like/route 곡μ©)
export async function getActorData(actorUrl: string, username: string) {
const res = await signedFetch(actorUrl, username);
if (!res.ok) throw new Error(`getActorData failed: ${res.status} ${await res.text()}`);
const data = await res.json();
return {
id: data.id,
inbox: data.inbox
};
}
— myapp23/app/users/[username]/inbox/route.tsμ Like μ²λ¦¬ μΆκ°
export async function POST(req: Request, { params }: { params: Promise<{ username: string }> }) {
... ...
if (body.type === 'Undo') {
// 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);
// β
myapp15 - μΈνλ‘μ° μ usernameλ 쑰건μ μΆκ° / Add username condition when unfollowing
db.prepare('DELETE FROM followers WHERE actor = ? AND username = ?').run(unfollowActorId, username);
console.log(`ποΈ [${username}] μΈνλ‘μ° / unfollow : ${unfollowActorId}`);
}
// β
myapp23 - Like Undo
else if (objType === 'Like' || obj?.id?.includes('/likes/')) {
const likeId = typeof obj === 'string'? obj : obj.id;
db.prepare('DELETE FROM likes WHERE id =?').run(likeId);
console.log(`π [${username}] Unlike μ μ₯ / unlike : ${likeId} by ${actorId}`);
}
}
}
... ...
// β
myapp23 - μ’μμ λ°κΈ° / Get Likes !
if (body.type === 'Like') {
try {
const likeId = body.id;
const objectId = typeof body.object === 'string'? body.object : body.object?.id;
console.log(`β€οΈ [${username}] Like λμ°©: ${actorId} -> ${objectId}`);
db.prepare('INSERT OR IGNORE INTO likes (id, actor, object, username) VALUES (?,?,?,?)')
.run(likeId, actorId, objectId, username);
console.log(`β
[${username}] likes μ μ₯ μλ£: ${likeId}`);
} catch (e) {
console.error(`β Like μ μ₯ μ€ν¨/
Failed to save 'Like'`, e);
}
return new Response('', { status: 202 });
}
π ν μ€νΈ / Test
βοΈ pinaforeμμ μ’μμ λλ₯΄κΈ° / Like on Pinafore

— λ΄ μλ² λ‘κ·Έ / My server log
GET /users/user1/posts/c0ee1d4b-b09a-4062-b0e9-9b3b40f50bb3 404 in 328ms (next.js: 266ms, application-code: 62ms)
π© [user1] INBOX: Like https://freelifemakers.com/users/user1
β οΈ [user1] publicKey μμ, κ²μ¦ μ€ν΅ / no publicKey, skip verify
β€οΈ [user1] Like λμ°©: https://freelifemakers.com/users/user1 -> https://aloy-horizon.duckdns.org/users/user1/posts/c0ee1d4b-b09a-4062-b0e9-9b3b40f50bb3
β
[user1] likes μ μ₯ μλ£: https://freelifemakers.com/users/user1/liked/01M0XS4TFGAA6G2YPYE72P0YRA
POST /users/user1/inbox 202 in 668ms (next.js: 547ms, application-code: 121ms)
— λ°μ΄ν°λ² μ΄μ€ / Database(SQLITE)
1)likesν μ΄λΈ / likes table
myapp23 % sqlite3 data.sqlite
SQLite version 3.51.0 2025-06-12 13:14:41
Enter ".help" for usage hints.
sqlite> select * from likes;
https://freelifemakers.com/users/user1/liked/01M0XS4TFGAA6G2YPYE72P0YRA|https://freelifemakers.com/users/user1|https://aloy-horizon.duckdns.org/users/user1/posts/c0ee1d4b-b09a-4062-b0e9-9b3b40f50bb3|user1|2026-04-10 00:10:26
sqlite>
βοΈ freelifemakers.comμ κΈμ μ’μμ λλ₯΄κΈ° / Like posts on freelifemakers.com
— POST λ©μλλ‘ νΉμ ν¬μ€νΈμ μ’μμ λλ₯΄κΈ°
Like a specific post using the POST method.
curl -X POST https://aloy-horizon.duckdns.org/api/like \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/users/user1/statuses/[POST_ID]"}'
myapp23 % curl -X POST https://aloy-horizon.duckdns.org/api/like \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/users/user1/statuses/01M0XT1VBF23F8Y5EXAPK1VY56"}'
{"ok":true,"result":{"ok":true,"status":202,"text":"{\"status\":\"Accepted\"}","likeDoc":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1/likes/1787708354555","type":"Like","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://freelifemakers.com/users/user1/statuses/01M0XT1VBF23F8Y5EXAPK1VY56"}}}%
gimdaegyeong@gimdaegyeong-ui-MacBookAir myapp23 %
1) κΈ°μ‘΄ κΈ κ²μ / Search existing posts
myapp23 % sqlite3 data.sqlite
SQLite version 3.51.0 2025-06-12 13:14:41
Enter ".help" for usage hints.
sqlite> select * from inbox_posts;
https://freelifemakers.com/users/user1/statuses/01M0EJPP57WC47FAK0N395S7MB|https://freelifemakers.com/users/user1|<p>inbox_posts table test</p>|user1|2026-08-20 03:16:15|
https://freelifemakers.com/users/user1/statuses/01M0KMS8K1XRFW703S32MX6V25|https://freelifemakers.com/users/user1|<p>actor test</p>|user1|2026-08-22T11:28:48+09:00|https://freelifemakers.com/users/user1/statuses/01M0KMS8K1XRFW703S32MX6V25
01M0XT1VBF23F8Y5EXAPK1VY56|https://freelifemakers.com/users/user1|<p>likes test</p>|user1|2026-08-26T10:13:17+09:00|https://freelifemakers.com/users/user1/statuses/01M0XT1VBF23F8Y5EXAPK1VY56
sqlite>
2) μ’μμ μ€ννκΈ° / like posts on
A. ν°λ―Έλμμ μ€ννκΈ° / Running in the terminal
curl -X POST https://aloy-horizon.duckdns.org/api/like \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/users/user1/statuses/01M0XT1VBF23F8Y5EXAPK1VY56"}'
B. μλ΅λ©μΈμ§(μ±κ³΅μΈκ²½μ°) / Response message (in case of success)
{"ok":true,"result":{"ok":true,"status":202,"text":"{\"status\":\"Accepted\"}","likeDoc":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1/likes/1787708354555","type":"Like","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://freelifemakers.com/users/user1/statuses/01M0XT1VBF23F8Y5EXAPK1VY56"}}}%

C.λ€λ₯Έ λ§μ€ν λ(https://mastodon.social/users/Gargron/statuses/114559081070832514)μ κ²μλ¬Όμ μ’μμ λλ₯΄κΈ°(μ±κ³΅)
Liking a post from another Mastodon instance
(https://mastodon.social/users/Gargron/statuses/114559081070832514) (Success)
– λ€λ₯Έ λ§μ€ν λ κ²μλ¬Όμ μ’μμ μ€ννκΈ° / Like other Mastodon posts
myapp23 % curl -X POST https://aloy-horizon.duckdns.org/api/like \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://mastodon.social/@Gargron/114559081070832514"}'
– μλ΅μ±κ³΅ / Response successful
{"ok":true,"result":{"ok":true,"status":202,"text":"","likeDoc":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1/likes/af39dead-cb1a-45ba-bf9d-f3074921c2a0","type":"Like","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://mastodon.social/users/Gargron/statuses/114559081070832514"}}}%
3) μ’μμ μ·¨μ μ€ν / Perform Undo
A.gotosocial – freelifemakers.com
– μ’μμ μ·¨μ μ μ‘ / Undo Send
curl -X DELETE https://aloy-horizon.duckdns.org/api/like \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/users/user1/statuses/01M0XT1VBF23F8Y5EXAPK1VY56"}'
– μλ΅μ±κ³΅ / Response successful
{"ok":true,"result":{"ok":true,"status":202,"text":"{\"status\":\"Accepted\"}","undoDoc":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1#undo/75038dda-f43b-4167-b49a-376ce2a0a29f","type":"Undo","actor":"https://aloy-horizon.duckdns.org/users/user1","object":{"id":"https://aloy-horizon.duckdns.org/users/user1/likes/09d8e157-6f0e-4320-af76-2bf337faf2f6","type":"Like","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://freelifemakers.com/users/user1/statuses/01M0XT1VBF23F8Y5EXAPK1VY56"}}}}
B.mastodon – https://mastodon.social/users/Gargron/statuses/114559081070832514
– μ’μμ μ·¨μ μ μ‘ / Undo Send
curl -X DELETE https://aloy-horizon.duckdns.org/api/like \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://mastodon.social/@Gargron/114559081070832514"}'
– μλ΅μ±κ³΅ / Response successful
{"ok":true,"result":{"ok":true,"status":202,"text":"","undoDoc":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1#undo/9aabf284-68a7-42a2-9761-1b7ee0b7bcb2","type":"Undo","actor":"https://aloy-horizon.duckdns.org/users/user1","object":{"id":"https://aloy-horizon.duckdns.org/users/user1/likes/af39dead-cb1a-45ba-bf9d-f3074921c2a0","type":"Like","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://mastodon.social/users/Gargron/statuses/114559081070832514"}}}}%
μν볡μ 8μ₯ 32μ / John 8:32
“κ·Έλ¦¬κ³ λν¬λ μ§λ¦¬λ₯Ό μκ² λ κ²μ΄λ©°, μ§λ¦¬κ° λν¬λ₯Ό μμ λ‘κ² ν κ²μ΄λ€.”
“Then you will know the truth ,and the truth will set you free”