ππ» myapp24μμλ Announce (Boost) λ₯Ό ꡬνν©λλ€.
myapp24 implements Announce (Boost).
ππ» λΆμ€νΈλ λ€λ₯Έ μ¬λμ΄ μ΄ κΈμ λ΄ νλ‘μμκ² μ리λ κΈ°λ₯μ
λλ€.
Boost is a feature that shares posts written by others with your followers.
ππ» λ΄κ° μ΄ κΈμ λΆμ€νΈλ₯Ό ν μ μμ΅λλ€.
I cannot boost the posts I have written.
π μ 체 νλ‘μ νΈ κ΅¬μ‘° / Overall Project Structure
myapp24/
βββ app/ (Next.js App Router)
β βββ .well-known/webfinger/route.ts -> webfinger
β βββ api/follow/route.ts -> Follow API(temporary)
β βββ api/announce/route.ts -> Boost(Announcement)
β βββ api/like/route.ts. -> Like API
β βββ api/posts/route.ts -> Writing API
β βββ api/timeline/route.ts -> Timeline 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
π νλ‘μ νΈ μμ(myapp24)
Project Start (myapp24)
npx create-next-app@latest
π SQLiteμ€μΉ / Installing SQLite
cd ~/myapp24
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
CREATE TABLE IF NOT EXISTS announces (
id TEXT PRIMARY KEY,
username TEXT,
object TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_announces_obj_user ON announces(object, username);
CREATE INDEX IF NOT EXISTS idx_announces_username ON announces(username);
π λΌμ°νΈμΆκ° / Add route
βοΈ myapp24/app/api/announce/route.ts μμ±
// myapp24/app/api/announce/route.ts
// myapp24 β
import { NextRequest, NextResponse } from 'next/server';
import db from '@/lib/db';
import { signedFetch, getActorData, sendAnnounce, sendUndoAnnounce } from '@/lib/ap';
const DOMAIN = process.env.DOMAIN || 'aloy-horizon.duckdns.org';
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 });
}
if (target.startsWith(`https://${DOMAIN}/users/${username}`)) {
return NextResponse.json({ ok: false, error: 'cannot boost own post' }, { 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 existing = db.prepare('SELECT id FROM announces WHERE object = ? AND username = ?').get(apObjectId, username) as any;
if (existing) {
return NextResponse.json({ ok: true, alreadyAnnounced: true, id: existing.id });
}
const result = await sendAnnounce(inbox, apObjectId, username);
if (result.ok) {
db.prepare('INSERT INTO announces (id, username, object) VALUES (?, ?, ?)').run(result.announceDoc.id, username, apObjectId);
}
return NextResponse.json({ ok: true, result });
} catch (e: any) {
console.error('[Announce 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 row = db.prepare('SELECT id FROM announces WHERE object = ? AND username = ?').get(apObjectId, username) as any;
if (!row) {
return NextResponse.json({ ok: true, alreadyUnAnnounced: true });
}
const result = await sendUndoAnnounce(inbox, row.id, apObjectId, username);
db.prepare('DELETE FROM announces WHERE id = ?').run(row.id);
return NextResponse.json({ ok: true, result });
} catch (e: any) {
console.error('[Announce DELETE]', e);
return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
}
}
π μ½λ μμ / Code Modification
βοΈmyapp24/lib/db.ts
db.exec(`
-- myapp24 β
Boost(Announcement)
CREATE TABLE IF NOT EXISTS announces (
id TEXT PRIMARY KEY,
username TEXT,
object TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
... ...
-- myapp24 β
CREATE INDEX IF NOT EXISTS idx_announces_obj_user ON announces(object, username);
CREATE INDEX IF NOT EXISTS idx_announces_username ON announces(username);
`);
βοΈmyapp24/lib/ap.ts
... ...
// getActorDataμλμ μ½λ μΆκ°νκΈ°
// Add code below getActorData
// β
myapp24 - Send Announce(Boost)
export async function sendAnnounce(toInbox: string, targetPostId: string, username: string) {
const actorId = `https://${DOMAIN}/users/${username}`;
const announceId = `${actorId}/announces/${crypto.randomUUID()}`;
const doc = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: announceId,
type: 'Announce',
actor: actorId,
object: targetPostId,
published: new Date().toISOString(),
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: [`${actorId}/followers`, `${actorId}`],
};
const body = JSON.stringify(doc);
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}] Announce μ μ‘ -> ${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(`π¬ Announce κ²°κ³Ό: ${res.status}`, text);
return { ok: res.ok, status: res.status, text, announceDoc: doc };
}
// β
myapp24 - Undo Announce
export async function sendUndoAnnounce(toInbox: string, announceId: string, targetPostId: string, username: string) {
const actorId = `https://${DOMAIN}/users/${username}`;
const undoId = `${actorId}#undo/${crypto.randomUUID()}`;
const undoDoc = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: undoId,
type: 'Undo',
actor: actorId,
published: new Date().toISOString(),
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: [`${actorId}/followers`],
object: {
id: announceId,
type: 'Announce',
actor: actorId,
object: targetPostId,
published: new Date().toISOString(),
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: [`${actorId}/followers`],
}
};
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 Announce μ μ‘ -> ${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 Announce κ²°κ³Ό: ${res.status}`, text);
return { ok: res.ok, status: res.status, text, undoDoc };
}
π ν μ€νΈ / Test
βοΈ POST,DELETE λ©μλ λΌμ°νΈ
POST and DELETE method routes
# -- freelifemakers.com(gotosocial) --
# λΆμ€νΈ / Boost
curl -X POST https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/@user1/statuses/01M10AHFHCKGF9BY5G4H50VSHG"}'
# λΆμ€νΈ μ·¨μ / Cancle Boost
curl -X DELETE https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/@user1/statuses/01M10AHFHCKGF9BY5G4H50VSHG"}'
# -- mastodon server ---
# λΆμ€νΈ / Boost
curl -X POST https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://mastodon.social/@mcnees/117089028658055432"}'
# λΆμ€νΈ μ·¨μ / Cancle Boost
curl -X DELETE https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://mastodon.social/@mcnees/117089028658055432"}'
# λ΄ κΈ λΆμ€νΈ μλ -> 400 μλ¬ λμΌ μ μ
# Attempting to boost my post -> A 400 error is the expected result.
curl -X POST https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://aloy-horizon.duckdns.org/users/user1/statuses/e6c99652-572f-491a-9e61-05e67b0d58fc"}'
βοΈ GTS Server
# GTS Boost
myapp24 % curl -X POST https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/@user1/statuses/01M10AHFHCKGF9BY5G4H50VSHG"}'
{"ok":true,"result":{"ok":true,"status":202,"text":"{\"status\":\"Accepted\"}","announceDoc":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1/announces/66cb2e85-0bd0-4e6c-856a-d77070cc3807","type":"Announce","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://freelifemakers.com/users/user1/statuses/01M10AHFHCKGF9BY5G4H50VSHG","published":"2026-08-27T00:50:15.162Z","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://aloy-horizon.duckdns.org/users/user1/followers","https://aloy-horizon.duckdns.org/users/user1"]}}}%
gimdaegyeong@gimdaegyeong-ui-MacBookAir myapp24 %
# GTS Boost Cancle
myapp24 % curl -X DELETE https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/@user1/statuses/01M10AHFHCKGF9BY5G4H50VSHG"}'
{"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/34cb45a0-c1f1-4bcb-bb64-2cb9a0aba982","type":"Undo","actor":"https://aloy-horizon.duckdns.org/users/user1","published":"2026-08-27T00:53:42.406Z","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://aloy-horizon.duckdns.org/users/user1/followers"],"object":{"id":"https://aloy-horizon.duckdns.org/users/user1/announces/66cb2e85-0bd0-4e6c-856a-d77070cc3807","type":"Announce","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://freelifemakers.com/users/user1/statuses/01M10AHFHCKGF9BY5G4H50VSHG","published":"2026-08-27T00:53:42.406Z","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://aloy-horizon.duckdns.org/users/user1/followers"]}}}}%

βοΈ Mastodon Server
— κ²μλ¬Ό μ£Όμ / Post address
https://mastodon.social/@mcnees/117089028658055432
# Boost
myapp24 % curl -X POST https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://mastodon.social/@mcnees/117089028658055432"}'
{"ok":true,"result":{"ok":true,"status":202,"text":"","announceDoc":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1/announces/827c782f-9422-43d4-bc14-0fed1cf648b6","type":"Announce","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://mastodon.social/users/mcnees/statuses/117089028658055432","published":"2026-08-27T00:58:19.780Z","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://aloy-horizon.duckdns.org/users/user1/followers","https://aloy-horizon.duckdns.org/users/user1"]}}}%
gimdaegyeong@gimdaegyeong-ui-MacBookAir myapp24 %
# Cancle Boost
myapp24 % curl -X DELETE https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://mastodon.social/@mcnees/117089028658055432"}'
{"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/3c643e41-dcb6-4e5c-ae6c-21c0ab5820b3","type":"Undo","actor":"https://aloy-horizon.duckdns.org/users/user1","published":"2026-08-27T00:59:54.564Z","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://aloy-horizon.duckdns.org/users/user1/followers"],"object":{"id":"https://aloy-horizon.duckdns.org/users/user1/announces/827c782f-9422-43d4-bc14-0fed1cf648b6","type":"Announce","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://mastodon.social/users/mcnees/statuses/117089028658055432","published":"2026-08-27T00:59:54.564Z","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://aloy-horizon.duckdns.org/users/user1/followers"]}}}}%

βοΈ λ΄ κΈ λΆμ€νΈ μ°¨λ¨νμΈ / Check if my post is blocked from the Boost feature
myapp24 % curl -X POST https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://aloy-horizon.duckdns.org/users/user1/statuses/e6c99652-572f-491a-9e61-05e67b0d58fc"}'
{"ok":false,"error":"cannot boost own post"}%
π μ§κΈκΉμ§ μμ
ν λ΄μ©κ³Ό λΌμ°νΈ μ
λλ€.
Here is the work done so far and the routes.
βοΈ λΈλΌμ°μ μμ ν
μ€νΈ ν μ μλ λΌμ°νΈ
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 list)
https://aloy-horizon.duckdns.org/users/user1/outbox
# 6. νμλΌμΈ / Timeline
# - νμλΌμΈUI / Timeline UI
https://aloy-horizon.duckdns.org/usersui/user1
# or
https://aloy-horizon.duckdns.org/@user1
# - νμλΌμΈAPI / Timeline API
https://aloy-horizon.duckdns.org/api/timeline?username=user1
# 7. κ°λ³ κΈ(κΈ μμΈ) / indivisual posts(Post Details)[posts, inbox_posts]
https://aloy-horizon.duckdns.org/users/user1/statuses/[ID]
# ex)
# posts table
https://aloy-horizon.duckdns.org/users/user1/statuses/e6c99652-572f-491a-9e61-05e67b0d58fc
# inbox_posts table
https://aloy-horizon.duckdns.org/users/user1/statuses/01M0P8VYEQTFW28N97W6MG8PY5
βοΈ ν°λ―Έλμμλ§ ν
μ€νΈ κ°λ₯ν λΌμ°νΈ
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"}'
# μ’μμ μ€ν / Likes
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"}'
# μ’μμ μ·¨μνκΈ° / Undo Likes
curl -X DELETE https://aloy-horizon.duckdns.org/api/like \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://mastodon.social/@Gargron/114559081070832514"}'
# λΆμ€νΈ μ€ν / Boost
curl -X POST https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/@user1/statuses/01M10AHFHCKGF9BY5G4H50VSHG"}'
# λΆμ€νΈ μ·¨μ / Cancle Boost
curl -X DELETE https://aloy-horizon.duckdns.org/api/announce \
-H "Content-Type: application/json" \
-d '{"username":"user1","target":"https://freelifemakers.com/@user1/statuses/01M10AHFHCKGF9BY5G4H50VSHG"}'
μν볡μ 8μ₯ 32μ / John 8:32
“κ·Έλ¦¬κ³ λν¬λ μ§λ¦¬λ₯Ό μκ² λ κ²μ΄λ©°, μ§λ¦¬κ° λν¬λ₯Ό μμ λ‘κ² ν κ²μ΄λ€.”
“Then you will know the truth ,and the truth will set you free”