ํ
์ด ํํ ๋ฆฌ์ผ์ ๋ค์ ์ธ์ด๋ก๋ ์ ๊ณต๋ฉ๋๋ค: English (์์ด), ๆฅๆฌ่ช (์ผ๋ณธ์ด).
์๋ด
๋ง์ฝ ์ฐํฉ์ฐ์ฃผ (fediverse)๋ ActivityPub ๊ฐ์ ์ฉ์ด๊ฐ ์์ํ๋ค๋ฉด, ๊ด๋ จ ๊ฒ์์ ์ข ๋ ํ๊ณ ๋์ ์ด ํํ ๋ฆฌ์ผ์ ๋ฐ๋ผํ ๊ฒ์ ๊ถํฉ๋๋ค.
์ด ํํ ๋ฆฌ์ผ์์๋ ActivityPub ์๋ฒ ํ๋ ์์ํฌ์ธ Fedify ๋ฅผ ์ด์ฉํ์ฌ Mastodon ์ด๋ Misskey ๊ฐ์ ActivityPub ํ๋กํ ์ฝ์ ๊ตฌํํ๋ ๋ง์ดํฌ๋ก๋ธ๋ก๊ทธ (microblog)๋ฅผ ๋ง๋ค์ด ๋ณด๋๋ก ํ๊ฒ ์ต๋๋ค. ์ด ํํ ๋ฆฌ์ผ์ Fedify์ ๊ธฐ๋ฐ ๋์ ์๋ฆฌ๋ฅผ ํ์
ํ๋ ๊ฒ๋ณด๋ค๋ Fedify์ ํ์ฉ๋ฒ์ ์ข ๋ ์ง์คํ๋ ค๊ณ ํฉ๋๋ค.
Fedify๋ ActivityPub์ด๋ ๊ทธ ์ธ ํ์ค(์ด์นญํ์ฌ ใ์ฐํฉ์ฐ์ฃผใ๋ผ ๋ถ๋ฆฌ๋)์ ์ด์ฉํ์ฌ ์ฐํฉ ์๋ฒ ์ฑ์ ๋ง๋ค๊ธฐ ์ํ TypeScript ๋ผ์ด๋ธ๋ฌ๋ฆฌ์
๋๋ค. ์ฐํฉ ์๋ฒ ์ฑ์ ๋ง๋ค ๋์ ๋ณต์กํจ์ด๋ ๋ฒ๊ฑฐ๋ก์ด ๋ณด์ผ๋ฌํ๋ ์ดํธ ์ฝ๋๋ฅผ ์์ ๊ณ , ๋น์ฆ๋์ค ๋ก์ง๊ณผ ์ฌ์ฉ์ ๊ฒฝํ์ ์ง์คํ ์ ์๋๋ก ํ๋ ๊ฒ์ด Fedify์ ๋ชฉํ์
๋๋ค.
Fedify ํ๋ก์ ํธ์ ๊ด์ฌ์ด ์๊ธฐ์
จ๋ค๋ฉด, ์๋์ ์๋ฃ๋ฅผ ์ฐธ๊ณ ํด ์ฃผ์ธ์:
Fedify๋ ๋ณธ ํํ ๋ฆฌ์ผ์ ๋ํ ์ง๋ฌธ์ด๋ ์ ์, ํผ๋๋ฐฑ ๋ฑ์ GitHub Discussions (์์ด)์ ์ฌ๋ ค ์ฃผ์๊ฑฐ๋ ์ฐํฉ์ฐ์ฃผ @ fedify (์์ด ๋ฐ ํ๊ตญ์ด)๋ก ๋ฉ์
์ฃผ์๊ธฐ ๋ฐ๋๋๋ค. ์๋๋ฉด Fedify ํ๋ก์ ํธ์ Discord ์๋ฒ ์ ๋ค์ด์ค์
์ #fedify-general-ko ์ฑ๋(ํ๊ตญ์ด)์์ ๋ง์ํ์
๋ ๋ฉ๋๋ค.
๋์ ๋
์
์ด ํํ ๋ฆฌ์ผ์ Fedify๋ฅผ ๋ฐฐ์์ ActivityPub ์๋ฒ ์ํํธ์จ์ด๋ฅผ ๋ง๋ค์ด ๋ณด๊ณ ์ถ์ ๋ถ๋ค์ ๋์์ผ๋ก ํฉ๋๋ค.
์ฌ๋ฌ๋ถ์ด HTML์ด๋ HTTP๋ฅผ ์ด์ฉํ์ฌ ์น์ฑ์ ์ ์ํด ๋ณธ ๊ฒฝํ์ด ์์ผ๋ฉฐ, ๋ช
๋ นํ ์ธํฐํ์ด์ค๋ SQL, JSON, ๊ธฐ๋ณธ์ ์ธ JavaScript ๋ฑ์ ์ดํดํ๋ค๊ณ ๊ฐ์ ํฉ๋๋ค. ํ์ง๋ง TypeScript๋ JSX , ActivityPub, Fedify ๋ฑ์ ์ด ํํ ๋ฆฌ์ผ์์ ํ์ํ ๋งํผ ๊ฐ๋ฅด์ณ ๋๋ฆด ๊ฒ์ด๋ ๋ชฐ๋ผ๋ ๊ด์ฐฎ์ต๋๋ค.
ActivityPub ์ํํธ์จ์ด๋ฅผ ๋ง๋ค์ด ๋ณธ ๊ฒฝํ์ ํ์ ์์ง๋ง, ๊ทธ๋๋ Mastodon์ด๋ Misskey ๊ฐ์ ActivityPub ์ํํธ์จ์ด๋ฅผ ํ๋ ์ ๋๋ ์จ๋ดค๋ค๊ณ ๊ฐ์ ํฉ๋๋ค. ๊ทธ๋์ผ ์ฐ๋ฆฌ๊ฐ ๋ฌด์์ ๋ง๋๋ ค๊ณ ํ๋์ง ๊ฐ์ด ์กํ๊ธฐ ๋๋ฌธ์
๋๋ค.
๋ชฉํ
์ด ํํ ๋ฆฌ์ผ์์๋ Fedify๋ฅผ ์ด์ฉํด ActivityPub์ผ๋ก ๋ค๋ฅธ ์ฐํฉํ ์ํํธ์จ์ด ๋ฐ ์๋น์ค์ ์ํต ๊ฐ๋ฅํ ์ผ์ธ์ฉ ๋ง์ดํฌ๋ก๋ธ๋ก๊ทธ๋ฅผ ๋ง๋ญ๋๋ค. ์ด ์ํํธ์จ์ด๋ ๋ค์๊ณผ ๊ฐ์ ๊ธฐ๋ฅ์ ํฌํจํฉ๋๋ค.
์ฌ์ฉ์๋ ๋จ ํ๋์ ๊ณ์ ์ ๋ง๋ค ์ ์์ต๋๋ค.
์ฐํฉ์ฐ์ฃผ ๋ด ๋ค๋ฅธ ๊ณ์ ์ด ์ฌ์ฉ์๋ฅผ ํ๋ก ํ ์ ์์ต๋๋ค.
ํ๋ก์๋ ์ฌ์ฉ์๋ฅผ ํ๋กํ๋ค๊ฐ ๊ทธ๋ง ๋ ์ ์์ต๋๋ค.
์ฌ์ฉ์๋ ์์ ์ ํ๋ก์ ๋ชฉ๋ก์ ๋ณผ ์ ์์ต๋๋ค.
์ฌ์ฉ์๋ ๊ฒ์๋ฌผ์ ์ฌ๋ฆด ์ ์์ต๋๋ค.
์ฌ์ฉ์์ ๊ฒ์๋ฌผ์ ์ฐํฉ์ฐ์ฃผ ๋ด ํ๋ก์๋ค์๊ฒ ๋ณด์
๋๋ค.
์ฌ์ฉ์๋ ์ฐํฉ์ฐ์ฃผ ๋ด ๋ค๋ฅธ ๊ณ์ ์ ํ๋ก ํ ์ ์์ต๋๋ค.
์ฌ์ฉ์๋ ์์ ์ด ํ๋กํ๋ ๊ณ์ ๋ชฉ๋ก์ ๋ณผ ์ ์์ต๋๋ค.
์ฌ์ฉ์๋ ์์ ์ด ํ๋กํ๋ ๊ณ์ ์ด ์์ฑํ ๊ฒ์๋ฌผ๋ค์ ์๊ฐ์ ๋ชฉ๋ก์ผ๋ก ๋ณผ ์ ์์ต๋๋ค.
ํํ ๋ฆฌ์ผ์ ๋จ์ํํ๊ธฐ ์ํด ๋ค์๊ณผ ๊ฐ์ ๊ธฐ๋ฅ ์ ์ฝ์ ๋ก๋๋ค.
๊ณ์ ํ๋กํ(์๊ฐ๋ฌธ, ์ฌ์ง ๋ฑ)์ ์ค์ ํ ์ ์์ต๋๋ค.
ํ ๋ฒ ๋ง๋ ๊ณ์ ์ ์ญ์ ํ ์ ์์ต๋๋ค.
ํ ๋ฒ ์ฌ๋ฆฐ ๊ฒ์๋ฌผ์ ๊ณ ์น๊ฑฐ๋ ์ง์ธ ์ ์์ต๋๋ค.
ํ ๋ฒ ํ๋กํ ๋ค๋ฅธ ๊ณ์ ์ ํ๋ก์์ ๊ทธ๋ง ๋ ์ ์์ต๋๋ค.
์ข์์, ๊ณต์ , ๋๊ธ์ ์์ต๋๋ค.
๊ฒ์ ๊ธฐ๋ฅ์ ์์ต๋๋ค.
์ธ์ฆ ๋ฐ ๊ถํ ๊ฒ์ฌ ๋ฑ์ ๋ณด์ ๊ธฐ๋ฅ์ ์์ต๋๋ค.
๋ฌผ๋ก , ํํ ๋ฆฌ์ผ์ ๋๊น์ง ์งํํ ๋ค ๊ธฐ๋ฅ์ ๋ง๋ถ์ด๋ ๊ฒ์ ์ผ๋ง๋ ์ง ํ์
๋ ์ข์ต๋๋ค. ์ข์ ์ฐ์ต์ด ๋ ๊ฒ์
๋๋ค.
์์ฑ๋ ์์ค ์ฝ๋๋ GitHub ์ ์ฅ์ ์ ์ฌ๋ผ์ ์์ผ๋ฉฐ, ๊ฐ ๊ตฌํ ๋จ๊ณ์ ๋ฐ๋ผ ์ปค๋ฐ์ด ๋๋์ด์ ธ ์์ผ๋ ์ฐธ๊ณ ๋ฐ๋๋๋ค.
๊ฐ๋ฐ ํ๊ฒฝ ์
์
Node.js ์ค์นํ๊ธฐ
Fedify๋ Deno , Bun , Node.js , ์ด ์ธ ๊ฐ์ง JavaScript ๋ฐํ์์ ์ง์ํฉ๋๋ค. ๊ทธ ์ค์์ Node.js๊ฐ ๊ฐ์ฅ ๋๋ฆฌ ์ฐ์ด๋ฏ๋ก, ์ด ํํ ๋ฆฌ์ผ์์๋ Node.js๋ฅผ ๊ธฐ์ค์ผ๋ก ์ค๋ช
ํ๋๋ก ํ๊ฒ ์ต๋๋ค.
ํ
JavaScript ๋ฐํ์์ด๋ JavaScript ์ฝ๋๋ฅผ ์คํํ๋ ํ๋ซํผ์ ๋ปํฉ๋๋ค. ์น๋ธ๋ผ์ฐ์ ๋ JavaScript ๋ฐํ์์ ํ๋์ด๋ฉฐ, ๋ช
๋ น์ค์ด๋ ์๋ฒ์์๋ Node.js ๋ฑ์ด ๋๋ฆฌ ์ฐ์
๋๋ค. ์ต๊ทผ์๋ Cloudflare Workers ๊ฐ์ ํด๋ผ์ฐ๋ ์์ง ํจ์๋ค๋ JavaScript ๋ฐํ์์ ํ๋๋ก ๊ฐ๊ด ๋ฐ๊ณ ์์ต๋๋ค.
Fedify๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด์๋ Node.js 22.0.0 ์ด์์ ๋ฒ์ ์ด ํ์ํฉ๋๋ค. ์ฌ๋ฌ ์ค์น๋ฒ ์ด ์์ผ๋ ์์ ์๊ฐ ๊ฐ์ฅ ์๋ง๋ ๋ฐฉ๋ฒ์ผ๋ก Node.js๋ฅผ ์ค์นํ์๊ธฐ ๋ฐ๋๋๋ค.
Node.js๊ฐ ์ค์น๋๋ฉด node ๋ช
๋ น์ด์ npm ๋ช
๋ น์ด๊ฐ ์๊น๋๋ค:
node --version
npm --version
fedify ๋ช
๋ น์ด ์ค์น
Fedify ํ๋ก์ ํธ๋ฅผ ์
์
ํ๊ธฐ ์ํด fedify ๋ช
๋ น์ด๋ฅผ ์์คํ
์ ์ค์นํด์ผ ํฉ๋๋ค. ์ฌ๋ฌ ์ค์น ๋ฐฉ๋ฒ ์ด ์์ง๋ง, npm ๋ช
๋ น์ผ๋ก ๊น๋ ๊ฒ์ด ๊ฐ์ฅ ๊ฐํธํฉ๋๋ค:
npm install -g @fedify/cli
์ค์น๊ฐ ๋์๋ค๋ฉด, fedify ๋ช
๋ น์ด๋ฅผ ์ธ ์ ์๋์ง ํ์ธํด ๋ด
์๋ค. ์๋ ๋ช
๋ น์ผ๋ก fedify ๋ช
๋ น์ด์ ๋ฒ์ ์ ์ ์ ์์ต๋๋ค.
fedify --version
๊ฒฐ๊ณผ๋ก ๋์จ ๋ฒ์ ๋ฒํธ๊ฐ 1.0.0 ์ด์์ธ์ง ํ์ธํ์ญ์์ค. ๊ทธ๋ณด๋ค ์๋ ๋ฒ์ ์ด๋ฉด ์ด ํํ ๋ฆฌ์ผ์ ์ ๋๋ก ๋ฐ๋ผํ ์ ์์ต๋๋ค.
fedify init์ผ๋ก ํ๋ก์ ํธ ์ด๊ธฐํ
์ Fedify ํ๋ก์ ํธ๋ฅผ ์์ํ๊ธฐ ์ํด, ์์
ํ ๋๋ ํฐ๋ฆฌ ๊ฒฝ๋ก๋ฅผ ์ ํฉ์๋ค. ์ด ํํ ๋ฆฌ์ผ์์๋ microblog ๋ผ๊ณ ๋ช
๋ช
ํ๊ฒ ์ต๋๋ค. fedify init ๋ช
๋ น ๋ค์ ๋๋ ํฐ๋ฆฌ ๊ฒฝ๋ก๋ฅผ ์ ๊ณ ์คํํฉ๋๋ค (๋๋ ํฐ๋ฆฌ๊ฐ ์์ง ์กด์ฌํ์ง ์์๋ ๊ด์ฐฎ์ต๋๋ค):
fedify init microblog
fedify init ๋ช
๋ น์ ์คํํ๋ฉด ์๋์ ๊ฐ์ด ๋ช ๊ฐ์ง ์ง๋ฌธ ํ๋กฌํํธ๊ฐ ๋์ต๋๋ค. ์ฐจ๋ก๋๋ก Node.js , npm , Hono , In-memory , In-process ๋ฅผ ์ ํํฉ๋๋ค:
___ _____ _ _ __
/'_') | ___|__ __| (_)/ _|_ _
.-^^^-/ / | |_ / _ \/ _` | | |_| | | |
__/ / | _| __/ (_| | | _| |_| |
<__.|_|-|_| |_| \___|\__,_|_|_| \__, |
|___/
? Choose the JavaScript runtime to use
Deno
Bun
โฏ Node.js
? Choose the package manager to use
โฏ npm
Yarn
pnpm
? Choose the web framework to integrate Fedify with
Bare-bones
Fresh
โฏ Hono
Express
Nitro
? Choose the key-value store to use for caching
โฏ In-memory
Redis
PostgreSQL
Deno KV
? Choose the message queue to use for background jobs
โฏ In-process
Redis
PostgreSQL
Deno KV
์๋ด
Fedify๋ ํ ์คํ ํ๋ ์์ํฌ๊ฐ ์๋, ActivityPub ์๋ฒ ๊ตฌํ์ ํนํ๋ ํ๋ ์์ํฌ์
๋๋ค. ๋ฐ๋ผ์, ๋ค๋ฅธ ์น ํ๋ ์์ํฌ์ ํจ๊ป ์ฐ์ด๋ ๊ฒ์ ์ผ๋์ ๋๊ณ ๋ง๋ค์ด์ก์ต๋๋ค. ์ด ํํ ๋ฆฌ์ผ์์๋ ์น ํ๋ ์์ํฌ๋ก Hono ๋ฅผ ์ฑํํ์ฌ Fedify์ ํจ๊ป ์ฌ์ฉํฉ๋๋ค.
๊ทธ๋ฌ๋ฉด ์ ์ ํ ์์
๋๋ ํฐ๋ฆฌ ์์ ๋ค์๊ณผ ๊ฐ์ ๊ตฌ์กฐ๋ก ํ์ผ๋ค์ด ์์ฑ๋๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค:
.vscode/ โ Visual Studio Code ๊ด๋ จ ์ค์ ๋ค
extensions.json โ Visual Studio Code ์ถ์ฒ ํ์ฅ
settings.json โ Visual Studio Code ์ค์
node_modules/ โ ์์กด ํจํค์ง๋ค์ด ์ค์น๋๋ ๋๋ ํฐ๋ฆฌ (๋ด๋ถ ์๋ต)
src/ โ ์์ค ์ฝ๋
app.tsx โ ActivityPub๊ณผ ๊ด๋ จ ์๋ ์๋ฒ
federation.ts โ ActivityPub ์๋ฒ
index.ts โ ์ํธ๋ฆฌํฌ์ธํธ
logging.ts โ ๋ก๊น
์ค์
biome.json โ ํฌ๋งคํฐ ๋ฐ ๋ฆฐํธ ์ค์
package.json โ ํจํค์ง ๋ฉํ๋ฐ์ดํฐ
tsconfig.json โ TypeScript ์ค์
์ง์ํ ์ ์๊ฒ ์ง๋ง, ์ฐ๋ฆฌ๋ JavaScript๊ฐ ์๋ TypeScript๋ฅผ ์ฐ๊ธฐ ๋๋ฌธ์ .js ํ์ผ์ด ์๋ .ts ๋ฐ .tsx ํ์ผ๋ค์ด ์์ต๋๋ค.
์์ฑ๋ ์์ค ์ฝ๋๋ ๋์ํ๋ ๋ฐ๋ชจ์
๋๋ค. ์ฐ์ ์ ์ด ์ํ๋ก ์ ๋์๊ฐ๋์ง ํ์ธํฉ์๋ค:
npm run dev
์ ๋ช
๋ น์ ์คํํ๋ฉด Ctrl +C ํค๋ฅผ ๋๋ฅด๊ธฐ ์ ๊น์ง๋ ์๋ฒ๊ฐ ์คํ๋ ์ฑ๋ก ์์ต๋๋ค:
Server started at http://0.0.0.0:8000
์๋ฒ๊ฐ ์คํ๋ ์ํ์์, ์ ํฐ๋ฏธ๋ ํญ์ ์ด๊ณ ์๋ ๋ช
๋ น์ ์คํํฉ๋๋ค:
fedify lookup http://localhost:8000/users/john
์ ๋ช
๋ น์ ์ฐ๋ฆฌ๊ฐ ๋ก์ปฌ์ ๋์ด ActivityPub ์๋ฒ์ ํ ์กํฐ(actor)๋ฅผ ์กฐํํ ๊ฒ์
๋๋ค. ActivityPub์์ ์กํฐ๋ ์ฌ๋ฌ ActivityPub ์๋ฒ๋ค ์ฌ์ด์์ ์ ๊ทผ ๊ฐ๋ฅํ ๊ณ์ ์ด๋ผ๊ณ ๋ณด์๋ฉด ๋ฉ๋๋ค.
์๋์ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ์ถ๋ ฅ๋๋ฉด ์ ์์
๋๋ค:
โ Looking up the object...
Person {
id: URL "http://localhost:8000/users/john",
name: "john",
preferredUsername: "john"
}
์ด ๊ฒฐ๊ณผ๋ฅผ ํตํด /users/john ๊ฒฝ๋ก์ ์์นํ ์กํฐ ๊ฐ์ฒด์ ์ข
๋ฅ๊ฐ Person์ด๋ฉฐ, ๊ทธ ID๋ http://localhost:8000/users/john , ์ด๋ฆ์ john , ์ฌ์ฉ์๋ช
๋ john ์ด๋ผ๋ ๊ฒ์ ์ ์ ์์ต๋๋ค.
ํ
fedify lookup์ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํํ๋ ๋ช
๋ น์ด์
๋๋ค. ์ด๋ Mastodon์์ ํด๋น URI๋ก ๊ฒ์ํ๋ ๊ฒ๊ณผ ๊ฐ์ ๋์์ ํฉ๋๋ค. (๋ฌผ๋ก , ํ์ฌ ์ฌ๋ฌ๋ถ์ ์๋ฒ๋ ๋ก์ปฌ์์๋ง ์ ๊ทผ ๊ฐ๋ฅํ๊ธฐ ๋๋ฌธ์ ์์ง Mastodon์์ ๊ฒ์ํด๋ ๊ฒฐ๊ณผ๊ฐ ๋์ค์ง๋ ์์ ๊ฒ์
๋๋ค.)
์ฌ๋ฌ๋ถ์ด fedify lookup ๋ช
๋ น์ด๋ณด๋ค curl์ ๋ ์ ํธํ์ ๋ค๋ฉด, ์๋ ๋ช
๋ น์ผ๋ก๋ ์กํฐ ์กฐํ๊ฐ ๊ฐ๋ฅํฉ๋๋ค (-H ์ต์
์ผ๋ก Accept ํค๋๋ฅผ ํจ๊ป ๋ณด๋ด๋ ๊ฒ์ ์ฃผ์ํ์ญ์์ค):
curl -H " Accept: application/activity+json " http://localhost:8000/users/john
๋จ, ์์ ๊ฐ์ด ์กฐํํ ๊ฒฝ์ฐ ๊ทธ ๊ฒฐ๊ณผ๋ ๋งจ๋์ผ๋ก ํ์ธํ๊ธฐ ์ด๋ ค์ด JSON ํ์์ด ๋ ๊ฒ์
๋๋ค. ๋ง์ฝ ์์คํ
์ jq ๋ช
๋ น์ด๋ ํจ๊ป ๊น๋ ค์๋ค๋ฉด, curl๊ณผ jq๋ฅผ ํจ๊ป ์ธ ์๋ ์์ต๋๋ค:
curl -H " Accept: application/activity+json " http://localhost:8000/users/john | jq .
Visual Studio Code
Visual Studio Code ๊ฐ ์ฌ๋ฌ๋ถ์ ์ต์ ์๋ํฐ๊ฐ ์๋ ์ ์์ต๋๋ค. ๊ทธ๋ ์ง๋ง, ์ด ํํ ๋ฆฌ์ผ์ ๋ฐ๋ผํ๋ ๋์์๋ Visual Studio Code๋ฅผ ์จ๋ณด์ค ๊ฒ์ ๊ถํฉ๋๋ค. ์๋ํ๋ฉด ์ฐ๋ฆฌ๋ TypeScript๋ฅผ ์จ์ผ ํ๋๋ฐ, Visual Studio Code๋ ํ์กดํ๋ ๊ฐ์ฅ ๊ฐํธํ๋ฉด์๋ ๋ฐ์ด๋ TypeScript IDE์ด๊ธฐ ๋๋ฌธ์
๋๋ค. ๋ํ, ์์ฑ๋ ํ๋ก์ ํธ ์
์
์ ์ด๋ฏธ Visual Studio Code ์ค์ ์ด ๊ฐ์ถฐ์ ธ ์๊ธฐ ๋๋ฌธ์ ํฌ๋งคํฐ๋ ๋ฆฐํธ ๋ฑ๊ณผ ์จ๋ฆํ ํ์๋ ์์ต๋๋ค.
๊ฒฝ๊ณ
Visual Studio์ ํท๊ฐ๋ฆฌ์๋ฉด ์ ๋ฉ๋๋ค. Visual Studio Code์ Visual Studio๋ ๋ธ๋๋๋ง ๊ณต์ ํ ๋ฟ ์๋ก ์์ ํ ๋ค๋ฅธ ์ํํธ์จ์ด์
๋๋ค.
Visual Studio Code๋ฅผ ์ค์น ํ์ ๋ค์, ํ์ผ โ ํด๋ ์ด๊ธฐโฆ ๋ฉ๋ด๋ฅผ ๋๋ฌ ์์
๋๋ ํฐ๋ฆฌ๋ฅผ ๋ถ๋ฌ์ค์ญ์์ค.
๋ง์ฝ ์ฐํ๋จ์ ใ์ด ๋ฆฌํฌ์งํ ๋ฆฌ์ ๋ํ ๊ถ์ฅ๋๋ biomejs์ โBiomeโ ํ์ฅ์(๋ฅผ) ์ค์นํ์๊ฒ ์ต๋๊น?ใ๋ผ๊ณ ๋ฌป๋ ์ฐฝ์ด ๋จ๋ฉด ์ค์น ๋ฒํผ์ ๋๋ฌ ํด๋น ํ์ฅ์ ์ค์นํ์ธ์. ์ด ํ์ฅ์ ์ค์นํ๋ฉด TypeScript ์ฝ๋๋ฅผ ์์ฑํ ๋ ๋ค์ฌ์ฐ๊ธฐ๋ ๋์ด์ฐ๊ธฐ ๊ฐ์ ์ฝ๋ ์คํ์ผ๊ณผ ์จ๋ฆํ ํ์ ์์ด ์๋์ผ๋ก ์ฝ๋๊ฐ ์์ํ ๋ฉ๋๋ค.
ํ
์ฌ๋ฌ๋ถ์ด ์ถฉ์ฑ์ค๋ฌ์ด Emacs ๋๋ Vim ์ฌ์ฉ์๋ผ๋ฉด, ์ฐ๋ ์ฌ๋ฌ๋ถ์ ์ต์ ์๋ํฐ๋ฅผ ์ฐ๋ ๊ฒ์ ๋ง๋ฆฌ์ง ์๊ฒ ์ต๋๋ค. ๋ค๋ง, TypeScript LSP ์ค์ ์ ์ง๊ณ ๋์ด๊ฐ ๊ฒ์ ๊ถํฉ๋๋ค. TypeScript LSP ์ค์ ์ฌ๋ถ์ ๋ฐ๋ผ ์์ฐ์ฑ์ ์ฐจ์ด๊ฐ ํฌ๊ธฐ ๋๋ฌธ์
๋๋ค.
์ ์ ์ง์
TypeScript
์ฝ๋๋ฅผ ์์ ํ๊ธฐ ์ ์, ๊ฐ๋จํ TypeScript์ ๋ํด ์ง๊ณ ๋์ด๊ฐ๋๋ก ํ๊ฒ ์ต๋๋ค. ๋ง์ฝ ์ฌ๋ฌ๋ถ์ด ์ด๋ฏธ TypeScript์ ์ต์ํ๋ค๋ฉด ์ด ์ฅ์ ๋๊ธฐ์
๋ ์ข์ต๋๋ค.
TypeScript๋ JavaScript์ ์ ์ ํ์
๊ฒ์ฌ๋ฅผ ์ถ๊ฐํ ๊ฒ์
๋๋ค. TypeScript ๋ฌธ๋ฒ์ JavaScript ๋ฌธ๋ฒ๊ณผ ๊ฑฐ์ ๊ฐ์ง๋ง, ๋ณ์๋ ํจ์ ๋ฌธ๋ฒ์ ํ์
์ ์ง์ ํ ์ ์๋ค๋ ๊ฒ์ด ํฐ ์ฐจ์ด์
๋๋ค. ํ์
์ง์ ์ ๋ณ์๋ ๋งค๊ฐ๋ณ์ ๋ค์ ์ฝ๋ก (:)์ ๋ถ์ฌ์ ๋ํ๋
๋๋ค.
์๋ฅผ ๋ค์ด, ๋ค์ ์ฝ๋๋ foo ๋ณ์๊ฐ ๋ฌธ์์ด(string)์ด๋ผ๋ ๊ฒ์ ๋ํ๋
๋๋ค:
let foo : string ;
๋ง์ฝ ์์ ๊ฐ์ด ์ ์ธ๋ foo ๋ณ์์ ๋ฌธ์์ด์ด ์๋ ๋ค๋ฅธ ํ์
์ ๊ฐ์ ๋์
ํ๋ ค๊ณ ํ๋ฉด Visual Studio Code๊ฐ ์คํํด๋ณด๊ธฐ ์ ์ ๋ฏธ๋ฆฌ ๋นจ๊ฐ ๋ฐ์ค์ ๊ทธ์ด์ฃผ๋ฉฐ ํ์
์ค๋ฅ๋ฅผ ๋ณด์ฌ์ค ๊ฒ์
๋๋ค:
foo = 123 ;
// ts(2322): 'number' ํ์์ 'string' ํ์์ ํ ๋นํ ์ ์์ต๋๋ค.
์ฝ๋ฉํ๋ฉด์ ๋นจ๊ฐ ๋ฐ์ค์ ๋ง๋๋ฉด ์ง๋์น์ง ์๋๋ก ํ์ญ์์ค. ๋ฌด์ํ๊ณ ํ๋ก๊ทธ๋จ์ ์คํํ๋ฉด ๊ทธ ๋ถ๋ถ์์ ์ค์ ๋ก ์ค๋ฅ๊ฐ ๋ ๊ฐ๋ฅ์ฑ์ด ๋์ต๋๋ค.
TypeScript๋ก ์ฝ๋ฉ์ ํ๋ฉฐ ๋ง์ฃผ์น๋ ๊ฐ์ฅ ํํ ํ์
์ค๋ฅ์ ์ ํ์ ๋ฐ๋ก null ๊ฐ๋ฅ์ฑ ์ค๋ฅ์
๋๋ค. ์๋ฅผ ๋ค์ด, ๋ค์ ์ฝ๋๋ฅผ ๋ณด๋ฉด bar ๋ณ์๋ ๋ฌธ์์ด(string)์ผ ์๋ ์์ง๋ง null์ผ ์๋ ์๋ค(string | null)๊ณ ๋์ด ์์ต๋๋ค:
const bar : string | null = someFunction ();
๋ง์ฝ ์ด ๋ณ์์ ๋ด์ฉ์์ ๊ฐ์ฅ ์ฒซ ๊ธ์๋ฅผ ๊บผ๋ด๋ ค๊ณ ๋ค์๊ณผ ๊ฐ์ด ์ฝ๋๋ฅผ ์ด๋ค๋ฉด ์ด๋ป๊ฒ ๋ ๊น์?
const firstChar = bar . charAr ( 0 );
// ts(18047): 'bar'์(๋) 'null'์ผ ์ ์์ต๋๋ค.
์์ ๊ฐ์ด ํ์
์ค๋ฅ๊ฐ ๋๊ฒ ๋ฉ๋๋ค. bar๊ฐ ์ด์ฉ ๋๋ null์ผ ์ ์๋๋ฐ, ๊ทธ ๊ฒฝ์ฐ์ null.charAt(0)์ ํธ์ถํ๋ฉด ์ค๋ฅ๊ฐ ๋ ์ ์์ผ๋ ์ฝ๋๋ฅผ ๊ณ ์น๋ผ๋ ์ด์ผ๊ธฐ์
๋๋ค. ๊ทธ๋ฐ ๊ฒฝ์ฐ์ ์๋์ ๊ฐ์ด null์ธ ๊ฒฝ์ฐ์ ์ฒ๋ฆฌ๋ฅผ ์ถ๊ฐํด ์ค์ผ ํฉ๋๋ค.
const firstChar = bar === null ? "" : bar . charAr ( 0 );
์ด์ ๊ฐ์ด TypeScript๋ ์ฝ๋ฉํ ๋ ๋ฏธ์ฒ ์๊ฐํ์ง ๋ชปํ๋ ๊ฒฝ์ฐ์ ์๋ฅผ ๋ ์ฌ๋ฆฌ๊ฒ ํด์ ๋ฒ๊ทธ๋ฅผ ๋ฏธ์ฐ์ ๋ฐฉ์งํ๋๋ก ๋์์ค๋๋ค.
๋, TypeScript์ ๋ถ์์ ์ธ ์ฅ์ ์ค ํ๋๋ ์๋ ์์ฑ์ด ๋๋ค๋ ๊ฒ์
๋๋ค. ์๋ฅผ ๋ค์ด, foo.๊น์ง ์
๋ ฅํ๋ฉด ๋ฌธ์์ด ๊ฐ์ฒด๊ฐ ๊ฐ์ง ๋ฉ์๋ ๋ชฉ๋ก์ด ๋์์ ๊ทธ ์ค์์ ๊ณ ๋ฅผ ์ ์์ต๋๋ค. ์ด๋ฅผ ํตํด ์ผ์ผํ ๋ฌธ์๋ฅผ ํ์ธํ์ง ์๊ณ ์๋ ๋น ๋ฅด๊ฒ ์ฝ๋ฉ์ด ๊ฐ๋ฅํฉ๋๋ค.
์ด ํํ ๋ฆฌ์ผ์ ๋ฐ๋ผํ๋ฉด์ TypeScript์ ๋งค๋ ฅ๋ ํจ๊ป ๋๋ผ์๊ธฐ ๋ฐ๋๋๋ค. ๋ฌด์๋ณด๋ค Fedify๋ TypeScript์ ํจ๊ป ์ธ ๋ ๊ฐ์ฅ ๊ฒฝํ์ด ์ข์ผ๋๊น์.
ํ
TypeScript๋ฅผ ์ ๋๋ก ์ฐฌ์ฐฌํ ๋ฐฐ์๋ณด๊ณ ์ถ์ผ์๋ค๋ฉด, ๊ณต์ TypeScript ํธ๋๋ถ ์ ์ฝ์ผ์ค ๊ฒ์ ์ถ์ฒํฉ๋๋ค. ์ ๋ถ ์ฝ๋๋ฐ ์ฝ 30๋ถ ์ ๋ ์์๋ฉ๋๋ค.
JSX
JSX๋ JavaScript ์ฝ๋ ์์ XML ๋๋ HTML์ ์ง์ด๋ฃ์ ์ ์๋๋ก ํ JavaScript์ ๋ฌธ๋ฒ ํ์ฅ์
๋๋ค. TypeScript์์๋ ์ธ ์ ์์ผ๋ฉฐ, ์ด ๊ฒฝ์ฐ์๋ TSX๋ผ๊ณ ๋ถ๋ฅด๊ธฐ๋ ํฉ๋๋ค. ์ด ํํ ๋ฆฌ์ผ์์๋ ๋ชจ๋ HTML์ JSX ๋ฌธ๋ฒ์ ํตํด JavaScript ์ฝ๋ ์์ ์์ฑํ ๊ฒ์
๋๋ค. JSX์ ์ด๋ฏธ ์ต์ํ ๋ถ๋ค์ ์ด ์ฅ์ ๋๊ธฐ์
๋ ๋ฉ๋๋ค.
์๋ฅผ ๋ค์ด, ์๋ ์ฝ๋๋ <div> ์๋ฆฌ๋จผํธ๊ฐ ์ต์์์ ์๋ HTML ํธ๋ฆฌ๋ฅผ html ๋ณ์์ ๋์
ํฉ๋๋ค:
const html = < div >
< p id = " greet " > ์๋
, < strong > JSX </ strong > ! </ p >
</ div >;
์ค๊ดํธ๋ฅผ ํตํด JavaScript ํํ์์ ๋ฃ๋ ๊ฒ๋ ๊ฐ๋ฅํฉ๋๋ค (์๋ ์ฝ๋๋ ๋ฌผ๋ก getName() ํจ์๊ฐ ์๋ค๊ณ ๊ฐ์ ํฉ๋๋ค):
const html = < div title ={ " ์๋
, " + getName () + " ! " }>
< p id = " greet " > ์๋
, < strong >{ getName ()}</ strong > ! </ p >
</ div >;
JSX์ ํน์ง ์ค ํ๋๋ ์ปดํฌ๋ํธ(component)๋ผ๊ณ ๋ถ๋ฆฌ๋ ์์ ๋ง์ ํ๊ทธ๋ฅผ ์ ์ํ ์ ์๋ค๋ ๊ฒ์
๋๋ค. ์ปดํฌ๋ํธ๋ ํ๋ฒํ JavaScript ํจ์๋ก ์ ์ํ ์ ์์ต๋๋ค. ์๋ฅผ ๋ค์ด, ์๋ ์ฝ๋๋ <Container> ์ปดํฌ๋ํธ๋ฅผ ์ ์ํ๊ณ ์ฌ์ฉํ๋ ๊ฒ์ ๋ณด์ฌ์ค๋๋ค (์ปดํฌ๋ํธ ์ด๋ฆ์ ์ผ๋ฐ์ ์ผ๋ก PascalCase ์คํ์ผ์ ๋ฐ๋ฆ
๋๋ค):
import type { FC } from " hono/jsx " ;
function getName () {
return " JSX " ;
}
interface ContainerProps {
name : string ;
}
const Container : FC < ContainerProps > = ( props ) => {
return < div title ={ " ์๋
, " + props . name + " ! " }>{ props . children }</ div >;
};
const html = < Container name ={ getName ()}>
< p id = " greet " > ์๋
, < strong >{ getName ()}</ strong > ! </ p >
</ Container >;
์ ์ฝ๋์์ FC๋ ์ฐ๋ฆฌ๊ฐ ์ธ ์น ํ๋ ์์ํฌ์ธ Hono ์์ ์ ๊ณตํ๋ ๊ฒ์ผ๋ก, ์ปดํฌ๋ํธ์ ํ์
์ ์ ์ํ๋ ๊ฒ์ ๋์์ค๋๋ค. FC๋ ์ ๋ค๋ฆญ ํ์
(generic type)์ธ๋ฐ, FC<ContainerProps>์ฒ๋ผ ํ์ด๊ดํธ ์์ ๋ค์ด๊ฐ๋ ํ์
๋ค์ด ๋ฐ๋ก ํ์
์ธ์๋ค์
๋๋ค. ์ฌ๊ธฐ์๋ ํ์
์ธ์๋ก ํ๋กญ(props) ํ์์ ์ง์ ํฉ๋๋ค. ํ๋กญ์ด๋, ์ปดํฌ๋ํธ์๊ฒ ๋๊ฒจ ์ค ๋งค๊ฐ๋ณ์๋ค์ ๊ฐ๋ฆฌํค๋ ๋ง์
๋๋ค. ์ ์ฝ๋์์๋ <Container> ์ปดํฌ๋ํธ์ ํ๋กญ ํ์์ผ๋ก ContainerProps ์ธํฐํ์ด์ค๋ฅผ ์ ์ธํ๊ณ ์ฌ์ฉํ์ต๋๋ค.
ํ
์ ๋ค๋ฆญ ํ์
์ ํ์
์ธ์๋ ์ฌ๋ฌ ๊ฐ๊ฐ ๋ ์ ์์ผ๋ฉฐ, ์ผํ๋ก ๊ฐ ์ธ์๋ฅผ ๊ตฌ๋ถํฉ๋๋ค. ์๋ฅผ ๋ค์ด, Foo<A, B>๋ ์ ๋ค๋ฆญ ํ์
Foo์ ํ์
์ธ์ A์ B๋ฅผ ๋์
ํ ๊ฒ์
๋๋ค.
๋ํ, ์ ๋ค๋ฆญ ํจ์๋ผ๋ ๊ฒ๋ ์์ผ๋ฉฐ, someFunction<A, B>(foo, bar)์ ๊ฐ์ด ํ๊ธฐํฉ๋๋ค.
ํ์
์ธ์๊ฐ ํ๋์ผ ๋๋ ํ์
์ธ์๋ฅผ ๊ฐ์ธ๋ ํ์ด๊ดํธ๊ฐ ๋ง์น XML/HTML ํ๊ทธ์ฒ๋ผ ๋ณด์ด์ง๋ง, JSX์ ๊ธฐ๋ฅ๊ณผ๋ ์๋ฌด ๊ด๋ จ์ด ์์ต๋๋ค.
FC<ContainerProps>
์ ๋ค๋ฆญ ํ์
FC์ ํ์
์ธ์ ContainerProps๋ฅผ ๋์
ํ ๊ฒ.
<Container>
<Container>๋ผ๋ ์ด๋ฆ์ ์ปดํฌ๋ํธ ํ๊ทธ๋ฅผ ์ฐ ๊ฒ. </Container>๋ก ๋ซ์์ผ ํจ.
ํ๋กญ์ผ๋ก ์ ๋ฌ๋๋ ๊ฒ๋ค ์ค children์ ํน๋ณํ ์ง๊ณ ๋์ด๊ฐ ํ์๊ฐ ์์ต๋๋ค. ๋ฐ๋ก ์ปดํฌ๋ํธ์ ์์ ์๋ฆฌ๋จผํธ๋ค์ด children ํ๋กญ์ผ๋ก ๋์ด์ค๊ธฐ ๋๋ฌธ์
๋๋ค. ๊ฒฐ๊ณผ์ ์ผ๋ก ์ ์ฝ๋์์ html ๋ณ์์๋ <div title="์๋
, JSX!"><p id="greet">์๋
, <strong>JSX</strong>!</p></div>๋ผ๋ HTML ํธ๋ฆฌ๊ฐ ๋์
๋๊ฒ ๋ฉ๋๋ค.
๊ณ์ ์์ฑ ํ์ด์ง
์, ์ด์ ๋ณธ๊ฒฉ์ ์ธ ๊ฐ๋ฐ์ ๋์
ํฉ์๋ค.
๊ฐ์ฅ ๋จผ์ ๋ง๋ค ๊ฒ์ ๋ฐ๋ก ๊ณ์ ์์ฑ ํ์ด์ง์
๋๋ค. ๊ณ์ ์ ๋ง๋ค์ด์ผ ๊ฒ์๋ฌผ๋ ์ฌ๋ฆฌ๊ณ ๋ค๋ฅธ ๊ณ์ ์ ํ๋ก ํ ์๋ ์๊ฒ ์ฃ . ๋ณด์ด๋ ๊ฒ๋ถํฐ ๋ง๋ค์ด ๋ด
์๋ค.
๋จผ์ src/views.tsx ํ์ผ์ ๋ง๋ญ๋๋ค. ๊ทธ๋ฆฌ๊ณ ๊ทธ ํ์ผ ์์ JSX๋ก <Layout> ์ปดํฌ๋ํธ๋ฅผ ์ ์ํฉ๋๋ค:
import type { FC } from " hono/jsx " ;
export const Layout : FC = ( props ) => (
< html lang = " en " >
< head >
< meta charset = " utf-8 " />
< meta name = " viewport " content = " width=device-width, initial-scale=1 " />
< meta name = " color-scheme " content = " light dark " />
< title > Microblog </ title >
< link
rel = " stylesheet "
href = " https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css "
/>
</ head >
< body >
< main class = " container " >{ props . children }</ main >
</ body >
</ html >
);
๋์์ธ์ ๋๋ฌด ๋ง์ ๊ณต์ ๋ค์ด์ง ์๊ธฐ ์ํด, Pico CSS ๋ผ๋ CSS ํ๋ ์์ํฌ๋ฅผ ์ฌ์ฉํ๊ธฐ๋ก ํฉ๋๋ค.
ํ
๋ณ์๋ ๋งค๊ฐ๋ณ์์ ํ์
์ TypeScript์ ํ์
๊ฒ์ฌ๊ธฐ๊ฐ ์ถ๋ก ํ ์ ์๋ ๊ฒฝ์ฐ, ์์ props ๊ฐ์ด ํ์
ํ๊ธฐ๋ฅผ ์๋ตํด๋ ๋ฌด๋ฐฉํฉ๋๋ค. ์ด๋ ๊ฒ ํ์
ํ๊ธฐ๊ฐ ์๋ต๋ ๊ฒฝ์ฐ์๋, Visual Studio Code์์ ๋ณ์ ์ด๋ฆ ์์ ๋ง์ฐ์ค ์ปค์๋ฅผ ๊ฐ์ ธ๋ค ๋๋ฉด ํด๋น ๋ณ์๊ฐ ์ด๋ค ํ์
์ธ์ง ํ์ธํ ์ ์์ต๋๋ค.
๊ทธ ๋ค์, ๊ฐ์ ํ์ผ์์ ๋ ์ด์์ ์์ ๋ค์ด๊ฐ <SetupForm> ์ปดํฌ๋ํธ๋ฅผ ์ ์ํฉ๋๋ค:
export const SetupForm : FC = () => (
<>
< h1 > Set up your microblog </ h1 >
< form method = " post " action = " /setup " >
< fieldset >
< label >
Username { " " }
< input
type = " text "
name = " username "
required
maxlength ={ 50 }
pattern = " ^[a-z0-9_\-]+$ "
/>
</ label >
</ fieldset >
< input type = " submit " value = " Setup " />
</ form >
</>
);
JSX์์๋ ์ต์์์ ํ๋์ ์๋ฆฌ๋จผํธ๋ง ๋ ์ ์๋๋ฐ, <SetupForm> ์ปดํฌ๋ํธ์์๋ <h1>๊ณผ <form> ๋ ๊ฐ์ ์๋ฆฌ๋จผํธ๋ฅผ ์ต์์์ ๋๊ณ ์์ต๋๋ค. ๊ทธ๋์ ์ด๋ฅผ ํ๋์ ์๋ฆฌ๋จผํธ์ฒ๋ผ ๋ฌถ์ด์ฃผ๊ธฐ ์ํด์ ๋น ํ๊ทธ ๋ชจ์์ <>์ </>๋ก ๊ฐ์์ต๋๋ค. ์ด๋ฅผ ํ๋๊ทธ๋จผํธ(fragment)๋ผ๊ณ ํฉ๋๋ค.
์ด์ ์ ์ํ ์ปดํฌ๋ํธ๋ค์ ์กฐํฉํ์ฌ ์ฌ์ฉํ ์ฐจ๋ก์
๋๋ค. src/app.tsx ํ์ผ์์ ์์ ์ ์ํ ๋ ์ปดํฌ๋ํธ๋ฅผ importํฉ๋๋ค:
import { Layout , SetupForm } from " ./views.tsx " ;
๊ทธ๋ฆฌ๊ณ ๋์ /setup ํ์ด์ง์์ ์์ ๋ง๋ ๊ณ์ ์์ฑ ์์์ ํ์ํฉ๋๋ค:
app . get ( " /setup " , ( c ) =>
c . html (
< Layout >
< SetupForm />
</ Layout >,
),
);
์, ๊ทธ๋ผ ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/setup ํ์ด์ง๋ฅผ ์ด์ด ๋ด
์๋ค. ์๋์ ๊ฐ์ ํ๋ฉด์ด ๋ณด์ฌ์ผ ์ ์์
๋๋ค:
์๋ด
JSX๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด์๋ ์์ค ํ์ผ์ ํ์ฅ์๊ฐ .jsx ๋๋ .tsx ์ฌ์ผ ํฉ๋๋ค. ์ด ์ฅ์์ ํธ์งํ ๋ ํ์ผ ๋ชจ๋ ํ์ฅ์๊ฐ .tsx ๋ผ๋ ์ฌ์ค์ ์ฃผ์ํ์ธ์.
๋ฐ์ดํฐ๋ฒ ์ด์ค ์
์
์, ๋ณด์ด๋ ๋ถ๋ถ์ ๊ตฌํํ์ผ๋, ์ด์ ๋์์ ๊ตฌํํด์ผ ํ ์ฐจ๋ก์
๋๋ค. ๊ณ์ ์ ๋ณด๋ฅผ ์ ์ฅํ ๊ณณ์ด ํ์ํ๋ฐ, SQLite ๋ฅผ ์ฐ๋๋ก ํฉ์๋ค. SQLite๋ ์์ ๊ท๋ชจ์ ์ ํ๋ฆฌ์ผ์ด์
์ ์๋ง๋ ๊ด๊ณํ ๋ฐ์ดํฐ๋ฒ ์ด์ค์
๋๋ค.
์ฐ์ ๊ณ์ ์ ๋ณด๋ฅผ ๋ด์ ํ
์ด๋ธ์ ์ ์ธํฉ์๋ค. ์์ผ๋ก ๋ชจ๋ ํ
์ด๋ธ ์ ์ธ์ src/schema.sql ํ์ผ์ ์์ฑํ๋๋ก ํ๊ฒ ์ต๋๋ค. ๊ณ์ ์ ๋ณด๋ users ํ
์ด๋ธ์ ๋ด์ต๋๋ค:
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1 ),
username TEXT NOT NULL UNIQUE CHECK ( trim ( lower (username)) = username
AND username <> ''
AND length (username) <= 50 )
);
์ฐ๋ฆฌ๊ฐ ๋ง๋ค ๋ง์ดํฌ๋ก๋ธ๋ก๊ทธ๋ ๋จ ํ๋์ ๊ณ์ ๋ง ์์ฑํ ์ ์์ ๊ฒ์ด๋ฏ๋ก, ๊ธฐ๋ณธ ํค์ธ id ์นผ๋ผ์ด 1 ์ด์ธ์ ๊ฐ์ ํ์ฉํ์ง ์๋๋ก ์ ์ฝ์ ๊ฑธ์์ต๋๋ค. ์ด๋ก์จ users ํ
์ด๋ธ์๋ ๋ ์ด์์ ๋ ์ฝ๋๋ฅผ ๋ด์ ์ ์๊ฒ ๋ฉ๋๋ค. ๋ํ, ๊ณ์ ์์ด๋๋ฅผ ๋ด์ username ์นผ๋ผ์ด ๋น ๋ฌธ์์ด์ด๋ ๋๋ฌด ๊ธด ๋ฌธ์์ด์ ํ์ฉํ์ง ์๋๋ก ์ ์ฝ์ ์คฌ์ต๋๋ค.
์ด์ users ํ
์ด๋ธ์ ์์ฑํ๊ธฐ ์ํด์ src/schema.sql ํ์ผ์ ์คํํด์ผ ํฉ๋๋ค. ์ด๋ฅผ ์ํด sqlite3 ๋ช
๋ น์ด๊ฐ ํ์ํ๋ฐ์, SQLite ์น์ฌ์ดํธ์์ ๋ฐ๊ฑฐ๋ ๊ฐ ํ๋ซํผ์ ํจํค์ง ๊ด๋ฆฌ์๋ก ์ค์นํ ์ ์์ต๋๋ค. macOS์ ๊ฒฝ์ฐ์๋ ์ด์์ฒด์ ์ ๋ด์ฅ๋์ด ์์ผ๋ฏ๋ก ๋ฐ๋ก ๋ฐ์ ํ์ ์์ต๋๋ค. ์ง์ ๋ฐ์ ๊ฒฝ์ฐ ์ด์์ฒด์ ์ ๋ง๋ sqlite-tools-*.zip ํ์ผ์ ๋ฐ์์ ์์ถ์ ํด์ ํ๋ฉด ๋ฉ๋๋ค. ํจํค์ง ๊ด๋ฆฌ์๋ฅผ ์ฌ์ฉํ๋ฉด ๋ค์ ๋ช
๋ น์ผ๋ก ์ค์นํ ์๋ ์์ต๋๋ค:
sudo apt install sqlite3 # Debian ๋ฐ Ubuntu
sudo dnf install sqlite # Fedora ๋ฐ RHEL
choco install sqlite # Chocolatey
scoop install sqlite # Scoop
winget install SQLite.SQLite # Windows Package Manager
์, sqlite3 ๋ช
๋ น์ด๊ฐ ์ค๋น๋์๋ค๋ฉด ์ด์ ์ด๋ฅผ ์ด์ฉํด์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ์ผ์ ์์ฑํฉ์๋ค:
sqlite3 microblog.sqlite3 < src/schema.sql
์ ๋ช
๋ น์ ์คํํ๋ฉด microblog.sqlite3 ํ์ผ์ด ์๊ธฐ๋๋ฐ, ์ด ์์ SQLite ๋ฐ์ดํฐ๊ฐ ์ ์ฅ๋ฉ๋๋ค.
์ฑ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ
์ด์ ์ ํฌ๊ฐ ๋ง๋๋ ์ฑ์์ SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ฌ์ฉํ ์ผ๋ง ๋จ์์ต๋๋ค. Node.js์์ SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด์๋ SQLite ๋๋ผ์ด๋ฒ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊ฐ ํ์ํ๋ฐ์, ์ ํฌ๋ better-sqlite3 ํจํค์ง๋ฅผ ์ฐ๋๋ก ํ๊ฒ ์ต๋๋ค. ํจํค์ง๋ npm ๋ช
๋ น์ผ๋ก ๊ฐ๋จํ๊ฒ ๊น ์ ์์ต๋๋ค:
npm add better-sqlite3
npm add --save-dev @types/better-sqlite3
ํ
@types/better-sqlite3 ํจํค์ง๋ TypeScript๋ฅผ ์ํด better-sqlite ํจํค์ง์ API์ ๋ํ ํ์
์ ๋ณด๋ฅผ ๋ด๊ณ ์์ต๋๋ค. ์ด ํจํค์ง๋ฅผ ์ค์นํด์ผ Visual Studio Code์์ ํธ์งํ ๋ ์๋ ์์ฑ์ด๋ ํ์
๊ฒ์ฌ๊ฐ ๊ฐ๋ฅํฉ๋๋ค.
์ด์ ๊ฐ์ด @types/ ๋ฒ์ ์์ ์๋ ํจํค์ง๋ฅผ Definitely Typed ํจํค์ง๋ผ๊ณ ํฉ๋๋ค. ์ด๋ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊ฐ TypeScript๋ก ์์ฑ๋์ง ์์์ ๋, ์ปค๋ฎค๋ํฐ์์ ํ์
์ ๋ณด๋ฅผ ์ถ๊ฐ ๊ธฐ์
ํ์ฌ ํจํค์ง๋ก ๋ง๋ ๊ฒ์
๋๋ค.
ํจํค์ง๋ฅผ ์ค์นํ์ผ๋, ์ด ํจํค์ง๋ฅผ ์ด์ฉํ์ฌ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ฐ๊ฒฐํ๋ ์ฝ๋๋ฅผ ์งญ์๋ค. src/db.ts ๋ผ๋ ์ ํ์ผ์ ๋ง๋ค์ด์ ์๋์ ๊ฐ์ด ์ฝ๋ฉํฉ๋๋ค:
import Database from " better-sqlite3 " ;
const db = new Database ( " microblog.sqlite3 " );
db . pragma ( " journal_mode = WAL " );
db . pragma ( " foreign_keys = ON " );
export default db ;
ํ
์ฐธ๊ณ ๋ก db.pragma() ํจ์๋ฅผ ํตํด ํ ์ค์ ์ ๋ค์๊ณผ ๊ฐ์ ํจ๊ณผ๋ฅผ ์ง๋๋๋ค:
journal_mode = WAL
SQLite์์ ์์์ ์ปค๋ฐ ๋ฐ ๋กค๋ฐฑ์ ๊ตฌํํ๋ ๋ฐฉ์์ผ๋ก ๋ก๊ทธ ์ ํ ๊ธฐ์
๋ชจ๋๋ฅผ ์ฑํํฉ๋๋ค. ์ด ๋ชจ๋๋ ๊ธฐ๋ณธ๊ฐ์ธ ๋กค๋ฐฑ ์ ๋ ๋ชจ๋์ ๋นํด ๋๋ถ๋ถ์ ๊ฒฝ์ฐ์์ ๋ ์ฑ๋ฅ์ด ๋ฐ์ด๋ฉ๋๋ค.
foreign_keys = ON
SQLite์์๋ ๊ธฐ๋ณธ์ ์ผ๋ก ์ธ๋ ํค ์ ์ฝ ์กฐ๊ฑด์ ๊ฒ์ฌํ์ง ์์ต๋๋ค. ์ด ์ค์ ์ ์ผ๋ฉด ์ธ๋ ํค ์ ์ฝ ์กฐ๊ฑด์ ๊ฒ์ฌํ๊ฒ ๋์ด ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ์ ์งํค๋ ๋ฐ์ ๋์์ด ๋ฉ๋๋ค.
๊ทธ๋ฆฌ๊ณ users ํ
์ด๋ธ์ ์ ์ฅ๋๋ ๋ ์ฝ๋๋ฅผ JavaScript์์ ํํํ๋ ํ์
์ ์ ์ธํฉ์๋ค. src/schema.ts ํ์ผ์ ๋ง๋ค๊ณ ์๋์ ๊ฐ์ด User ํ์
์ ์ ์ํฉ๋๋ค:
export interface User {
id : number ;
username : string ;
}
๋ ์ฝ๋ ์ฝ์
๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ฐ๊ฒฐํ์ผ๋, ๋ ์ฝ๋๋ฅผ ์ฝ์
ํ ์ฐจ๋ก์
๋๋ค.
src/app.tsx ํ์ผ์ ์ด์ด ๋ ์ฝ๋ ์ฝ์
์ ์ฐ์ผ db ๊ฐ์ฒด์ User ํ์
์ importํฉ๋๋ค:
import db from " ./db.ts " ;
import type { User } from " ./schema.ts " ;
POST /setup ํธ๋ค๋ฌ๋ฅผ ๊ตฌํํฉ๋๋ค:
app . post ( " /setup " , async ( c ) => {
// ๊ณ์ ์ด ์ด๋ฏธ ์๋์ง ๊ฒ์ฌ
const user = db . prepare < unknown [], User >( " SELECT * FROM users LIMIT 1 " ). get ();
if ( user != null ) return c . redirect ( " / " );
const form = await c . req . formData ();
const username = form . get ( " username " );
if ( typeof username !== " string " || ! username . match ( / ^ [ a-z0-9_- ] {1,50} $ / )) {
return c . redirect ( " /setup " );
}
db . prepare ( " INSERT INTO users (username) VALUES (?) " ). run ( username );
return c . redirect ( " / " );
});
์์ ๋ง๋ค์๋ GET /setup ํธ๋ค๋ฌ์๋ ๊ณ์ ์ด ์ด๋ฏธ ์๋์ง ๊ฒ์ฌํ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํฉ๋๋ค:
app . get ( " /setup " , ( c ) => {
// ๊ณ์ ์ด ์ด๋ฏธ ์๋์ง ๊ฒ์ฌ
const user = db . prepare < unknown [], User >( " SELECT * FROM users LIMIT 1 " ). get ();
if ( user != null ) return c . redirect ( " / " );
return c . html (
< Layout >
< SetupForm / >
< / Layout >,
);
});
ํ
์คํธ
์ด์ ๊ณ์ ์์ฑ ๊ธฐ๋ฅ์ด ์ผ์ถ ๊ตฌํ๋์์ผ๋, ํ ๋ฒ ์จ ๋ด
์๋ค. ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/setup ํ์ด์ง๋ฅผ ์ด์ด ๊ณ์ ์ ์์ฑํด ๋ณด์ธ์. ์ด ํํ ๋ฆฌ์ผ์์๋ ์์ผ๋ก ์์ด๋๋ก johndoe ๋ฅผ ์ผ๋ค๊ณ ๊ฐ์ ํ๊ฒ ์ต๋๋ค. ์์ฑ๋์๋ค๋ฉด, SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ ์ฝ๋๊ฐ ์ ์ฝ์
๋์๋ ํ์ธ๋ ํด ๋ด
๋๋ค:
echo " SELECT * FROM users; " | sqlite3 -table microblog.sqlite3
๋ ์ฝ๋๊ฐ ์ ์ฝ์
๋์๋ค๋ฉด ์๋์ ๊ฐ์ด ์ถ๋ ฅ๋ ๊ฒ์
๋๋ค (๋ฌผ๋ก , johndoe๋ ์ฌ๋ฌ๋ถ์ด ์
๋ ฅํ ์์ด๋์ ๋ฐ๋ผ ๋ฌ๋ผ์ง๊ฒ ์ฃ ):
ํ๋กํ ํ์ด์ง
์ด์ ๊ณ์ ์ด ์์ฑ๋์์ผ๋ ๊ณ์ ์ ๋ณด๋ฅผ ๋ณด์ฌ์ฃผ๋ ํ๋กํ ํ์ด์ง๋ฅผ ๊ตฌํํฉ์๋ค. ๋น๋ก ๋ณด์ฌ ์ค ์ ๋ณด๊ฐ ๊ฑฐ์ ์์ง๋ง์.
์ด๋ฒ์๋ ๋ณด์ด๋ ๊ฒ๋ถํฐ ์์
ํ๋๋ก ํ๊ฒ ์ต๋๋ค. src/views.tsx ํ์ผ์ <Profile> ์ปดํฌ๋ํธ๋ฅผ ์ ์ํฉ๋๋ค:
export interface ProfileProps {
name : string ;
handle : string ;
}
export const Profile : FC < ProfileProps > = ({ name , handle }) => (
<>
< hgroup >
< h1 >{ name }</ h1 >
< p style = " user-select: all; " >{ handle }</ p >
</ hgroup >
</>
);
๊ทธ๋ฆฌ๊ณ src/app.tsx ํ์ผ์์ ์ ์ํ ์ปดํฌ๋ํธ๋ฅผ importํฉ๋๋ค:
import { Layout , Profile , SetupForm } from " ./views.tsx " ;
๊ทธ๋ฆฌ๊ณ <Profile> ์ปดํฌ๋ํธ๋ฅผ ํ์ํ๋ GET /users/{username} ํธ๋ค๋ฌ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
app . get ( " /users/:username " , async ( c ) => {
const user = db
. prepare < unknown [], User >( " SELECT * FROM users WHERE username = ? " )
. get ( c . req . param ( " username " ));
if ( user == null ) return c . notFound ();
const url = new URL ( c . req . url );
const handle = ` @ ${ user . username } @ ${ url . host } ` ;
return c . html (
< Layout >
< Profile name ={ user . username } handle ={ handle } />
</ Layout >,
);
});
์ฌ๊ธฐ๊น์ง ํ๋ค๋ฉด ์ด์ ํ
์คํธ๋ฅผ ํด ๋ด์ผ๊ฒ ์ฃ ? ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/users/johndoe (๊ณ์ ์์ฑํ ๋ ์์ด๋๋ฅผ johndoe๋ก ํ์ ๊ฒฝ์ฐ; ์๋๋ผ๋ฉด URL์ ๋ฐ๊ฟ์ผ ํฉ๋๋ค) ํ์ด์ง๋ฅผ ์ด์ด ๋ณด์ธ์. ์๋์ ๊ฐ์ ํ๋ฉด์ด ๋์์ผ ํฉ๋๋ค:
ํ
์ฐํฉ์ฐ์ฃผ ํธ๋ค(fediverse handle), ์ค์ฌ์ ํธ๋ค์ด๋ ์ฐํฉ์ฐ์ฃผ ๋ด์์ ๊ณ์ ์ ๊ฐ๋ฆฌํค๋ ๊ณ ์ ํ ์ฃผ์ ๊ฐ์ ๊ฒ์
๋๋ค. ์๋ฅผ ๋ค๋ฉด @hongminhee@fosstodon.org์ฒ๋ผ ์๊ฒผ์ต๋๋ค. ์ด๋ฉ์ผ ์ฃผ์์ ๋น์ทํ๊ฒ ์๊ฒผ๋๋ฐ, ์ค์ ๊ตฌ์ฑ๋ ์ด๋ฉ์ผ ์ฃผ์์ ๋น์ทํฉ๋๋ค. ๋งจ ์ฒ์์ @์ด ์ค๊ณ , ๊ทธ ๋ค์์ ์ด๋ฆ, ๊ทธ๋ฆฌ๊ณ ๋ค์ @์ด ์จ ๋ค, ๋ง์ง๋ง์ ๊ณ์ ์ด ์ํ ์๋ฒ์ ๋๋ฉ์ธ ์ด๋ฆ์ด ์ต๋๋ค. ๋๋๋ก ๋งจ ์์ @์ด ์๋ต๋๊ธฐ๋ ํฉ๋๋ค.
๊ธฐ์ ์ ์ผ๋ก๋ ํธ๋ค์ WebFinger ์ acct: URI ํ์ ์ด๋ผ๋ ๋ ๊ฐ์ ํ์ค์ผ๋ก ๊ตฌํ๋ฉ๋๋ค. Fedify๊ฐ ์ด๋ฅผ ๊ตฌํํ๊ณ ์๊ธฐ ๋๋ฌธ์, ์ด ํํ ๋ฆฌ์ผ์ ์งํํ๋ ๋์ ์ฌ๋ฌ๋ถ์ ๊ตฌํ ์ธ๋ถ ์ฌํญ์ ์์ง ์์๋ ๊ด์ฐฎ์ต๋๋ค.
์กํฐ ๊ตฌํํ๊ธฐ
ActivityPub์ ๊ทธ ์ด๋ฆ์์๋ ๋๋ฌ๋๋ฏ, ์กํฐ๋นํฐ(activity)๋ฅผ ์ฃผ๊ณ ๋ฐ๋ ํ๋กํ ์ฝ์
๋๋ค. ๊ธ์ฐ๊ธฐ, ๊ธ ๊ณ ์น๊ธฐ, ๊ธ ์ง์ฐ๊ธฐ, ๊ธ์ ์ข์์ ์ฐ๊ธฐ, ๋๊ธ ๋ฌ๊ธฐ, ํ๋กํ ๊ณ ์น๊ธฐโฆ ์์
๋ฏธ๋์ด์์ ์ผ์ด๋๋ ๋ชจ๋ ์ผ๋ค์ ์กํฐ๋นํฐ๋ก ํํํฉ๋๋ค.
๊ทธ๋ฆฌ๊ณ ๋ชจ๋ ์กํฐ๋นํฐ๋ ์กํฐ(actor)์์ ์กํฐ๋ก ์ ์ก๋ฉ๋๋ค. ์๋ฅผ ๋ค์ด, ํ๊ธธ๋์ด ๊ธ์ ์ฐ๋ฉด ใ๊ธ์ฐ๊ธฐใ(Create(Note)) ์กํฐ๋นํฐ๊ฐ ํ๊ธธ๋์ผ๋ก๋ถํฐ ํ๊ธธ๋์ ํ๋ก์๋ค์๊ฒ ์ ์ก๋ฉ๋๋ค. ๊ทธ ๊ธ์ ์๊บฝ์ ์ด ์ข์์๋ฅผ ์ฐ์ผ๋ฉด ใ์ข์์ใ(Like) ์กํฐ๋นํฐ๊ฐ ์๊บฝ์ ์ผ๋ก๋ถํฐ ํ๊ธธ๋์๊ฒ ์ ์ก๋ฉ๋๋ค.
๋ฐ๋ผ์ ActivityPub์ ๊ตฌํํ๋ ๊ฐ์ฅ ์ฒซ๊ฑธ์์ ์กํฐ๋ฅผ ๊ตฌํํ๋ ๊ฒ์
๋๋ค.
fedify init ๋ช
๋ น์ผ๋ก ์์ฑ๋ ๋ฐ๋ชจ ์ฑ์ ์ด๋ฏธ ์์ฃผ ๊ฐ๋จํ ์กํฐ๊ฐ ๊ตฌํ๋์ด ์๊ธด ํ์ง๋ง, Mastodon์ด๋ Misskey ๊ฐ์ ์ค์ ์ ์ํํธ์จ์ด๋ค๊ณผ ์ํตํ๊ธฐ ์ํด์๋ ์กํฐ๋ฅผ ์ข ๋ ์ ๋๋ก ๊ตฌํํ ํ์๊ฐ ์์ต๋๋ค.
์ผ๋จ, ํ์ฌ์ ๊ตฌํ์ ํ ๋ฒ ์ดํด๋ณผ๊น์? src/federation.ts ํ์ผ์ ์ด์ด๋ด
์๋ค:
import { Person , createFederation } from " @fedify/fedify " ;
import { InProcessMessageQueue , MemoryKvStore } from " @fedify/fedify " ;
import { getLogger } from " @logtape/logtape " ;
const logger = getLogger ( " microblog " );
const federation = createFederation ({
kv : new MemoryKvStore (),
queue : new InProcessMessageQueue (),
});
federation . setActorDispatcher ( " /users/{identifier} " , async ( ctx , identifier ) => {
return new Person ({
id : ctx . getActorUri ( identifier ),
preferredUsername : identifier ,
name : identifier ,
});
});
export default federation ;
์ฐ๋ฆฌ๊ฐ ์ฃผ๋ชฉํด์ผ ํ ๋ถ๋ถ์ setActorDispatcher() ๋ฉ์๋์
๋๋ค. ์ด ๋ฉ์๋๋ ๋ค๋ฅธ ActivityPub ์ํํธ์จ์ด๊ฐ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์๋ฒ์ ์กํฐ๋ฅผ ์กฐํํ ๋ ์ธ URL๊ณผ ๊ทธ ํ๋์ ์ ์ํฉ๋๋ค. ์๋ฅผ ๋ค์ด ์ฐ๋ฆฌ๊ฐ ์์ ํ๋ ๊ฒ์ฒ๋ผ /users/johndoe ๋ฅผ ์กฐํํ๋ฉด ์ฝ๋ฐฑ ํจ์์ identifier ๋งค๊ฐ๋ณ์๋ก "johndoe"๋ผ๋ ๋ฌธ์์ด ๊ฐ์ด ๋ค์ด์ค๊ฒ ๋ฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ ์ฝ๋ฐฑ ํจ์๋ Person ํด๋์ค์ ์ธ์คํด์ค๋ฅผ ๋ฐํํ์ฌ ์กฐํํ ์กํฐ์ ์ ๋ณด๋ฅผ ์ ๋ฌํฉ๋๋ค.
ctx ๋งค๊ฐ๋ณ์๋ก๋ Context ๊ฐ์ฒด๊ฐ ๋์ด์ค๋๋ฐ, ActivityPub ํ๋กํ ์ฝ๊ณผ ๊ด๋ จ๋ ์ฌ๋ฌ ๊ธฐ๋ฅ์ ๋ด๊ณ ์๋ ๊ฐ์ฒด์
๋๋ค. ์๋ฅผ ๋ค์ด, ์ ์ฝ๋์์ ์ฐ์ด๊ณ ์๋ getActorUri() ๋ฉ์๋๋ ๋งค๊ฐ๋ณ์๋ก ์ ๋ฌ๋ identifier๊ฐ ๋ค์ด๊ฐ ์กํฐ์ ๊ณ ์ ํ URI๋ฅผ ๋ฐํํฉ๋๋ค. ์ด URI๋ Person ๊ฐ์ฒด์ ๊ณ ์ ์๋ณ์๋ก ์ฐ์ด๊ณ ์์ต๋๋ค.
๊ตฌํ ์ฝ๋๋ฅผ ๋ณด์๋ฉด ์๊ฒ ์ง๋ง, ํ์ฌ๋ /users/ ๊ฒฝ๋ก ๋ค์ ์ด๋ค ํธ๋ค์ด ์ค๋ ๋ถ๋ฅด๋ ๋๋ก ์กํฐ ์ ๋ณด๋ฅผ ์ง์ด๋ด์ ๋ฐํํ๊ณ ์์ต๋๋ค. ํ์ง๋ง ์ฐ๋ฆฌ๊ฐ ์ํ๋ ๊ฒ์ ์ค์ ๋ก ๋ฑ๋ก๋์ด ์๋ ๊ณ์ ์ ๋ํด์๋ง ์กฐํํ ์ ์๊ฒ ํ๋ ๊ฒ์
๋๋ค. ์ด ๋ถ๋ถ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์๋ ๊ณ์ ์ ๋ํด์๋ง ๋ฐํํ๋๋ก ๊ณ ์ณ๋ณด๋๋ก ํฉ์๋ค.
ํ
์ด๋ธ ์์ฑ
actors ํ
์ด๋ธ์ ๋ง๋ค์ด์ผ ํฉ๋๋ค. ์ด ํ
์ด๋ธ์ ํ์ฌ ์ธ์คํด์ค ์๋ฒ์ ๊ณ์ ๋ง ๋ด๋ users ํ
์ด๋ธ๊ณผ ๋ฌ๋ฆฌ, ์ฐํฉ๋๋ ์๋ฒ๋ค์ ์ํ ์๊ฒฉ ์กํฐ๋ค๊น์ง๋ ๋ด์ต๋๋ค. ํ
์ด๋ธ์ ๋ค์๊ณผ ๊ฐ์ด ์๊ฒผ์ต๋๋ค. src/schema.sql ํ์ผ์ ๋ค์ SQL์ ๋ง๋ถ์ด์ธ์:
CREATE TABLE IF NOT EXISTS actors (
id INTEGER NOT NULL PRIMARY KEY ,
user_id INTEGER REFERENCES users (id),
uri TEXT NOT NULL UNIQUE CHECK (uri <> '' ),
handle TEXT NOT NULL UNIQUE CHECK (handle <> '' ),
name TEXT ,
inbox_url TEXT NOT NULL UNIQUE CHECK (inbox_url LIKE ' https://% '
OR inbox_url LIKE ' http://% ' ),
shared_inbox_url TEXT CHECK (shared_inbox_url
LIKE ' https://% '
OR shared_inbox_url
LIKE ' http://% ' ),
url TEXT CHECK ( url LIKE ' https://% '
OR url LIKE ' http://% ' ),
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
CHECK (created <> '' )
);
user_id ์นผ๋ผ์ users ์นผ๋ผ๊ณผ ์ฐ๊ฒฐํ๊ธฐ ์ํ ์ธ๋ ํค์
๋๋ค. ํด๋น ๋ ์ฝ๋๊ฐ ์๊ฒฉ ์กํฐ๋ฅผ ํํํ ๊ฒฝ์ฐ์๋ NULL์ด ๋ค์ด๊ฐ์ง๋ง, ํ์ฌ ์ธ์คํด์ค ์๋ฒ์ ๊ณ์ ์ด๋ผ๋ฉด ํด๋น ๊ณ์ ์ users.id ๊ฐ์ด ๋ค์ด๊ฐ๋๋ค.
uri ์นผ๋ผ์ ์กํฐ ID๋ผ๊ณ ๋ถ๋ฆฌ๋ ์กํฐ์ ๊ณ ์ URI๋ฅผ ๋ด์ต๋๋ค. ์กํฐ๋ฅผ ํฌํจํ์ฌ ๋ชจ๋ ActivityPub ๊ฐ์ฒด๋ URI ํํ์ ๊ณ ์ ID๋ฅผ ๊ฐ์ต๋๋ค. ๋ฐ๋ผ์ ๋น์ด ์์ ์ ์๊ณ ์ค๋ณต๋ ์๋ ์์ต๋๋ค.
handle ์นผ๋ผ์ @johndoe@example.com ๋ชจ์์ ์ฐํฉ์ฐ์ฃผ ํธ๋ค์ ๋ด์ต๋๋ค. ๋ง์ฐฌ๊ฐ์ง๋ก ๋น ์ ์์ผ๋ฉฐ ์ค๋ณต๋ ์๋ ์์ต๋๋ค.
name ์นผ๋ผ์ UI์ ํ์๋๋ ์ด๋ฆ์ ๋ด์ต๋๋ค. ๋ณดํต ํ๋ค์์ด๋ ๋๋ค์์ด ๋ค์ด๊ฐ๊ฒ ๋๊ฒ ์ฃ . ๋ค๋ง, ActivityPub ๋ช
์ธ์ ๋ฐ๋ผ ์ด ์นผ๋ผ์ ๋น ์ ์์ต๋๋ค.
inbox_url ์นผ๋ผ์ ํด๋น ์กํฐ์ ์์ ํจ(inbox) URL์ ๋ด์ต๋๋ค. ์์ ํจ์ด ๋ฌด์์ธ์ง์ ๋ํด์๋ ์๋์์ ์ ๋๋ก ์ค๋ช
ํ๊ฒ ์ต๋๋ค๋ง, ํ์ฌ๋ก์๋ ์กํฐ์๊ฒ ํ์์ ์ผ๋ก ์กด์ฌํด์ผ ํ๋ค๋ ๊ฒ๋ง ์์ ๋์๋ฉด ๋ฉ๋๋ค. ์ด ์นผ๋ผ ์ญ์ ๋น ์๋ ์๊ณ ์ค๋ณต๋ ์๋ ์์ต๋๋ค.
shared_inbox_url ์นผ๋ผ์ ํด๋น ์กํฐ์ ๊ณต์ ์์ ํจ(shared inbox) URL์ ๋ด๋๋ฐ, ์ด ์ญ์ ์๋์์ ์ ๋๋ก ์ค๋ช
ํ๊ฒ ์ต๋๋ค. ํ์๋ ์๋๋ฉฐ, ๋ฐ๋ผ์ ๋น ์ ์๊ณ ์นผ๋ผ ์ด๋ฆ ๊ทธ๋๋ก ๋ค๋ฅธ ์กํฐ๋ค๊ณผ ๊ฐ์ ๊ณต์ ์์ ํจ URL์ ๊ณต์ ํ ์๋ ์์ต๋๋ค.
url ์นผ๋ผ์ ํด๋น ์กํฐ์ ํ๋กํ URL์ ๋ด์ต๋๋ค. ํ๋กํ URL์ด๋ ์น๋ธ๋ผ์ฐ์ ์์ ์ด์ด์ ๋ณผ ์ ์๋ ํ๋กํ ํ์ด์ง์ URL์ ๋ปํฉ๋๋ค. ์กํฐ์ ID์ ํ๋กํ URL์ด ๋์ผํ ๊ฒฝ์ฐ๋ ์์ง๋ง, ์๋น์ค์ ๋ฐ๋ผ ๋ค๋ฅธ ๊ฒฝ์ฐ๋ ์๊ธฐ ๋๋ฌธ์, ๊ทธ ๊ฒฝ์ฐ์ ์ด ์นผ๋ผ์ ํ๋กํ URL์ ๋ด์ต๋๋ค. ๋น ์ ์์ต๋๋ค.
created ์นผ๋ผ์ ๋ ์ฝ๋๊ฐ ์์ฑ๋ ์์ ์ ๊ธฐ๋กํฉ๋๋ค. ๋น ์ ์์ผ๋ฉฐ, ๊ธฐ๋ณธ์ ์ผ๋ก ์ฝ์
์์ ์๊ฐ์ด ๊ธฐ๋ก๋ฉ๋๋ค.
์, ์ด์ src/schema.sql ํ์ผ์ microblog.sqlite3 ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ์ผ์ ์ ์ฉํฉ์๋ค:
sqlite3 microblog.sqlite3 < src/schema.sql
ํ
์์ users ํ
์ด๋ธ์ ์ ์ํ ๋ CREATE TABLE IF NOT EXISTS ๋ฌธ์ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์, ์ฌ๋ฌ ๋ฒ ์คํํด๋ ๊ด์ฐฎ์ต๋๋ค.
๊ทธ๋ฆฌ๊ณ actors ํ
์ด๋ธ์ ์ ์ฅ๋๋ ๋ ์ฝ๋๋ฅผ JavaScript๋ก ํํํ ํ์
๋ src/schema.ts ์ ์ ์ํฉ๋๋ค:
export interface Actor {
id : number ;
user_id : number | null ;
uri : string ;
handle : string ;
name : string | null ;
inbox_url : string ;
shared_inbox_url : string | null ;
url : string | null ;
created : string ;
}
์กํฐ ๋ ์ฝ๋
ํ์ฌ users ํ
์ด๋ธ์ ๋ ์ฝ๋๊ฐ ํ๋ ์๊ธด ํ์ง๋ง, ์ด์ ์ง์ด ๋ง๋ ๋ ์ฝ๋๊ฐ actors ํ
์ด๋ธ์๋ ์์ต๋๋ค. ๊ณ์ ์ ์์ฑํ ๋ actors ํ
์ด๋ธ์ ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํ์ง ์์๊ธฐ ๋๋ฌธ์
๋๋ค. ๊ณ์ ์์ฑ ์ฝ๋๋ฅผ ๊ณ ์ณ์ users์ actors ์์ชฝ์ ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํ๋๋ก ๋ง๋ค์ด์ผ ํฉ๋๋ค.
๋จผ์ src/views.tsx ์ ์๋ SetupForm์์ ์์ด๋์ ํจ๊ป actors.name ์นผ๋ผ์ ๋ค์ด๊ฐ ์ด๋ฆ๋ ์
๋ ฅ ๋ฐ๋๋ก ํฉ์๋ค:
export const SetupForm : FC = () => (
<>
< h1 > Set up your microblog </ h1 >
< form method = " post " action = " /setup " >
< fieldset >
< label >
Username { " " }
< input
type = " text "
name = " username "
required
maxlength ={ 50 }
pattern = " ^[a-z0-9_\-]+$ "
/>
</ label >
< label >
Name < input type = " text " name = " name " required />
</ label >
</ fieldset >
< input type = " submit " value = " Setup " />
</ form >
</>
);
์์ ์ ์ํ Actor ํ์
์ src/app.tsx ์์ importํฉ๋๋ค:
import type { Actor , User } from " ./schema.ts " ;
์ด์ ์
๋ ฅ ๋ฐ์ ์ด๋ฆ์ ๋น๋กฏํด ํ์ํ ์ ๋ณด๋ค์ actors ํ
์ด๋ธ์ ๋ ์ฝ๋๋ก ๋ง๋๋ ์ฝ๋๋ฅผ POST /setup ํธ๋ค๋ฌ์ ์ถ๊ฐํฉ๋๋ค:
app . post ( " /setup " , async ( c ) => {
// ๊ณ์ ์ด ์ด๋ฏธ ์๋์ง ๊ฒ์ฌ
const user = db
. prepare < unknown [], User >(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
LIMIT 1
` ,
)
. get ();
if ( user != null ) return c . redirect ( " / " );
const form = await c . req . formData ();
const username = form . get ( " username " );
if ( typeof username !== " string " || ! username . match ( / ^ [ a-z0-9_- ] {1,50} $ / )) {
return c . redirect ( " /setup " );
}
const name = form . get ( " name " );
if ( typeof name !== " string " || name . trim () === "" ) {
return c . redirect ( " /setup " );
}
const url = new URL ( c . req . url );
const handle = ` @ ${ username } @ ${ url . host } ` ;
const ctx = fedi . createContext ( c . req . raw , undefined );
db . transaction (() => {
db . prepare ( " INSERT OR REPLACE INTO users (id, username) VALUES (1, ?) " ). run (
username ,
);
db . prepare (
`
INSERT OR REPLACE INTO actors
(user_id, uri, handle, name, inbox_url, shared_inbox_url, url)
VALUES (1, ?, ?, ?, ?, ?, ?)
` ,
). run (
ctx . getActorUri ( username ). href ,
handle ,
name ,
ctx . getInboxUri ( username ). href ,
ctx . getInboxUri (). href ,
ctx . getActorUri ( username ). href ,
);
})();
return c . redirect ( " / " );
});
๊ณ์ ์ด ์ด๋ฏธ ์๋์ง ๊ฒ์ฌํ ๋, users ํ
์ด๋ธ์ ๋ ์ฝ๋๊ฐ ์์ ๋ ๋ฟ๋ง ์๋๋ผ ์ง์ด ๋ง๋ ๋ ์ฝ๋๊ฐ actors ํ
์ด๋ธ์ ์์ด๋ ์์ง ๊ณ์ ์ด ์๋ ๊ฒ์ผ๋ก ํ์ ํ๋๋ก ๊ณ ์ณค์ต๋๋ค. ๊ฐ์ ์กฐ๊ฑด์ GET /setup ํธ๋ค๋ฌ ๋ฐ GET /users/{username} ํธ๋ค๋ฌ์๋ ์ ์ฉํฉ๋๋ค:
app . get ( " /setup " , ( c ) => {
// Check if the user already exists
const user = db
. prepare < unknown [], User >(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
LIMIT 1
` ,
)
. get ();
if ( user != null ) return c . redirect ( " / " );
return c . html (
< Layout >
< SetupForm />
</ Layout >,
);
});
app . get ( " /users/:username " , async ( c ) => {
const user = db
. prepare < unknown [], User & Actor >(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
WHERE username = ?
` ,
)
. get ( c . req . param ( " username " ));
if ( user == null ) return c . notFound ();
const url = new URL ( c . req . url );
const handle = ` @ ${ user . username } @ ${ url . host } ` ;
return c . html (
< Layout >
< Profile name ={ user . name ?? user . username } handle ={ handle } />
</ Layout >,
);
});
ํ
TypeScript์์ A & B๋ A ํ์
์ธ ๋์์ B ํ์
์ธ ๊ฐ์ฒด๋ฅผ ๋ปํฉ๋๋ค. ์๋ฅผ ๋ค์ด, { a: number } & { b: string } ํ์
์ด ์๋ค๊ณ ํ ๋, { a: 123 }์ด๋ { b: "foo" }๋ ํด๋น ํ์
์ ๋ง์กฑํ์ง ๋ชปํ์ง๋ง, { a: 123, b: "foo" }๋ ํด๋น ํ์
์ ๋ง์กฑํฉ๋๋ค.
๋ง์ง๋ง์ผ๋ก, src/federation.ts ํ์ผ์ ์ด์ด, ์กํฐ ๋์คํจ์ฒ ์๋์ ๋ค์ ์ฝ๋๋ฅผ ์ถ๊ฐํฉ๋๋ค:
federation . setInboxListeners ( " /users/{identifier}/inbox " , " /inbox " );
setInboxListeners() ๋ฉ์๋๋ ์ง๊ธ์ผ๋ก์๋ ์ ๊ฒฝ ์ฐ์ง ๋ง์ธ์. ์ด ์ญ์ ์์ ํจ์ ๋ํด ์ค๋ช
ํ ๋ ํจ๊ป ๋ค๋ฃจ๋๋ก ํ๊ฒ ์ต๋๋ค. ๋ค๋ง, ๊ณ์ ์์ฑ ์ฝ๋์์ ์ฌ์ฉํ getInboxUri() ๋ฉ์๋๊ฐ ์ ๋๋ก ๋์ํ๋ ค๋ฉด ์ ์ฝ๋๊ฐ ํ์ํ๋ค๋ ์ ๋ง ์ง๊ณ ๋์ด๊ฐ๊ฒ ์ต๋๋ค.
์ฝ๋๋ฅผ ๋ชจ๋ ๊ณ ์ณค๋ค๋ฉด, ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/setup ํ์ด์ง๋ฅผ ์ด์ด์ ๋ค์ ๊ณ์ ์ ์์ฑํฉ๋๋ค:
์กํฐ ๋์คํจ์ฒ
actors ํ
์ด๋ธ์ ๋ง๋ค๊ณ ๋ ์ฝ๋๋ ์ฑ์ ์ผ๋, ๋ค์ src/federation.ts ํ์ผ์ ๊ณ ์ณ๋ด
์๋ค. ๋จผ์ db ๊ฐ์ฒด์ Endpoints ๋ฐ Actor๋ฅผ importํฉ๋๋ค:
import { Endpoints , Person , createFederation } from " @fedify/fedify " ;
import db from " ./db.ts " ;
import type { Actor , User } from " ./schema.ts " ;
ํ์ํ ๊ฒ๋ค์ importํ์ผ๋ setActorDispatcher() ๋ฉ์๋๋ฅผ ๊ณ ์ณ๋ด
์๋ค:
federation . setActorDispatcher ( " /users/{identifier} " , async ( ctx , identifier ) => {
const user = db
. prepare < unknown [], User & Actor >(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
WHERE users.username = ?
` ,
)
. get ( identifier );
if ( user == null ) return null ;
return new Person ({
id : ctx . getActorUri ( identifier ),
preferredUsername : identifier ,
name : user . name ,
inbox : ctx . getInboxUri ( identifier ),
endpoints : new Endpoints ({
sharedInbox : ctx . getInboxUri (),
}),
url : ctx . getActorUri ( identifier ),
});
});
๋ฐ๋ ์ฝ๋์์๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ users ํ
์ด๋ธ์ ์กฐํํ์ฌ ํ์ฌ ์๋ฒ์ ์๋ ๊ณ์ ์ด ์๋ ๊ฒฝ์ฐ null์ ๋ฐํํ๊ฒ ๋์์ต๋๋ค. ์ฆ, GET /users/johndoe (๊ณ์ ์ ์์ฑํ ๋ ์์ด๋๋ฅผ johndoe๋ก ์ ํ๋ค๊ณ ๊ฐ์ ํ ๊ฒฝ์ฐ) ์์ฒญ์ ๋ํด์๋ ์ฌ๋ฐ๋ฅธ Person ๊ฐ์ฒด๋ฅผ 200 OK์ ํจ๊ป ์๋ตํ ๊ฒ์ด๊ณ , ๊ทธ ์ธ์ ์์ฒญ์ ๋ํด์๋ 404 Not Found๋ฅผ ์๋ตํ๊ฒ ๋ฉ๋๋ค.
Person ๊ฐ์ฒด๋ฅผ ์์ฑํ๋ ๋ถ๋ถ๋ ์ด๋ป๊ฒ ๋ฐ๋์๋ ์ดํด๋ด
์๋ค. ๋จผ์ name ์์ฑ์ด ์ถ๊ฐ๋์์ต๋๋ค. ์ด ํ๋กํผํฐ๋ actors.name ์นผ๋ผ์ ๊ฐ์ ์ฌ์ฉํฉ๋๋ค. inbox์ endpoints ์์ฑ์ ์์ ํจ์ ๋ํด ์ค๋ช
ํ ๋ ํจ๊ป ๋ค๋ฃจ๋๋ก ํ๊ฒ ์ต๋๋ค. url ์์ฑ์ ์ด ๊ณ์ ์ ํ๋กํ URL์ ๋ด๋๋ฐ, ์ด ํํ ๋ฆฌ์ผ์์๋ ์กํฐ ID์ ์กํฐ์ ํ๋กํ URL์ ์ผ์น์ํค๋๋ก ํ๊ฒ ์ต๋๋ค.
ํ
๋์ฐ๋ฏธ๊ฐ ์ข์ ๋ถ๋ค์ ๋์น์ฑ์
จ๊ฒ ์ง๋ง, Hono์ Fedify ์์ชฝ์์ GET /users/{identifier}์ ๋ํ ํธ๋ค๋ฌ๋ฅผ ๊ฒน์ณ์ ์ ์ํ๊ณ ์์ต๋๋ค. ๊ทธ๋ผ ํด๋น ์์ฒญ์ ์ค์ ๋ก ๋ณด๋ด๋ฉด ์ด๋ ์ชฝ์์ ์๋ตํ๊ฒ ๋ ๊น์? ์ ๋ต์ ์์ฒญ์ Accept ํค๋์ ๋ฐ๋ผ ๋ฌ๋ผ์ง๋ค๋ ๊ฒ์
๋๋ค. Accept: text/html ํค๋์ ํจ๊ป ์์ฒญ์ ๋ณด๋ด๋ฉด Hono ์ชฝ ์์ฒญ ํธ๋ค๋ฌ์์ ์๋ตํฉ๋๋ค. Accept: application/activity+json ํค๋์ ํจ๊ป ์์ฒญ์ ๋ณด๋ด๋ฉด Fedify ์ชฝ ์์ฒญ ํธ๋ค๋ฌ์์ ์๋ตํฉ๋๋ค.
์ด๋ ๊ฒ ์์ฒญ์ Accept ํค๋์ ๋ฐ๋ผ ๋ค๋ฅธ ์๋ต์ ์ฃผ๋ ๋ฐฉ์์ HTTP ๋ด์ฉ ํ์ (content negotiation)์ด๋ผ๊ณ ํ๋ฉฐ, Fedify ์์ฒด์์ ๋ด์ฉ ํ์์ ๊ตฌํํฉ๋๋ค. ์ข ๋ ๊ตฌ์ฒด์ ์ผ๋ก๋, ๋ชจ๋ ์์ฒญ์ Fedify๋ฅผ ํ ๋ฒ ๊ฑฐ์น๊ฒ ๋๋ฉฐ, ActivityPub๊ณผ ๊ด๋ จ๋ ์์ฒญ์ด ์๋ ๊ฒฝ์ฐ ์ฐ๋๋ ํ๋ ์์ํฌ, ์ด ํํ ๋ฆฌ์ผ์์๋ Hono์๊ฒ ์์ฒญ์ ๊ฑด๋ด์ฃผ๊ฒ ๋์ด ์์ต๋๋ค.
ํ
Fedify์์๋ ๋ชจ๋ URI ๋ฐ URL์ URL ์ธ์คํด์ค๋ก ํํํฉ๋๋ค.
ํ
์คํธ
๊ทธ๋ผ ํ ๋ฒ ์กํฐ ๋์คํจ์ฒ๋ฅผ ํ
์คํธํด ๋ณผ๊น์?
์๋ฒ๊ฐ ์ผ์ง ์ํ์์, ์ ํฐ๋ฏธ๋ ํญ์ ์ด์ด ์๋ ๋ช
๋ น์ ์
๋ ฅํฉ๋๋ค:
fedify lookup http://localhost:8000/users/alice
alice์ด๋ผ๋ ๊ณ์ ์ด ์๊ธฐ ๋๋ฌธ์, ์๊น์๋ ๋ค๋ฅด๊ฒ ์ด์ ๋ค์๊ณผ ๊ฐ์ด ์ค๋ฅ๊ฐ ๋ ๊ฒ์
๋๋ค:
โ Looking up the object...
Failed to fetch the object.
It may be a private object. Try with -a/--authorized-fetch.
๊ทธ๋ผ johndoe ๊ณ์ ๋ ์กฐํํด ๋ด
์๋ค:
fedify lookup http://localhost:8000/users/johndoe
์ด์ ๋ ๊ฒฐ๊ณผ๊ฐ ์ ๋์ต๋๋ค:
โ Looking up the object...
Person {
id: URL "http://localhost:8000/users/johndoe",
name: "John Doe",
url: URL "http://localhost:8000/users/johndoe",
preferredUsername: "johndoe",
inbox: URL "http://localhost:8000/users/johndoe/inbox",
endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}
์ํธ ํค ์๋ค
๊ทธ ๋ค์ ๊ตฌํํ ๊ฒ์ ์๋ช
์ ์ํ ์กํฐ์ ์ํธ ํค๋ค์
๋๋ค. ActivityPub์ ์กํฐ๊ฐ ์กํฐ๋นํฐ๋ฅผ ๋ง๋ค์ด ์ ์กํ๋๋ฐ, ์ด ๋ ์กํฐ๋นํฐ๋ฅผ ์ ๋ง๋ก ํด๋น ์กํฐ๊ฐ ๋ง๋ค์๋ค๋ ๊ฒ์ ์ฆ๋ช
ํ๊ธฐ ์ํด ๋์งํธ ์๋ช
์ ํฉ๋๋ค. ์ด๋ฅผ ์ํด ์กํฐ๋ ์ง์ด ๋ง๋ ์์ ๋ง์ ๊ฐ์ธ ํค(๋น๋ฐ ํค) ๋ฐ ๊ณต๊ฐ ํค ์์ ๋ง๋ค์ด ๊ฐ๊ณ ์๊ณ , ๊ทธ ๊ณต๊ฐ ํค๋ฅผ ๋ค๋ฅธ ์กํฐ๋ค๋ ๋ณผ ์ ์๊ฒ ๊ณต๊ฐํฉ๋๋ค. ์กํฐ๋ค์ ์กํฐ๋นํฐ๋ฅผ ์์ ํ ๋ ๋ฐ์ ์์ ๊ณต๊ฐ ํค์ ์กํฐ๋นํฐ์ ์๋ช
์ ๋์กฐํ์ฌ ๊ทธ ์กํฐ๋นํฐ๊ฐ ์ ๋ง๋ก ๋ฐ์ ์๊ฐ ์์ฑํ ๊ฒ ๋ง๋์ง ํ์ธํฉ๋๋ค. ์๋ช
๊ณผ ์๋ช
๋์กฐ๋ Fedify๊ฐ ์์์ ํด ์ฃผ์ง๋ง, ํค ์์ ์์ฑํ๊ณ ๋ณด์กดํ๋ ๊ฒ์ ์ง์ ๊ตฌํํ์
์ผ ํฉ๋๋ค.
๊ฒฝ๊ณ
๊ฐ์ธ ํค(๋น๋ฐ ํค)๋ ์ด๋ฆ์์ ๋๋ฌ๋๋ฏ ์๋ช
ํ ์ฃผ์ฒด ์ด์ธ์๋ ์ ๊ทผํ ์ ์์ด์ผ ํฉ๋๋ค. ๋ฐ๋ฉด, ๊ณต๊ฐ ํค๋ ๊ทธ ์ฉ๋ ์์ฒด๊ฐ ๊ณต๊ฐํ๊ธฐ ์ํจ์ด๋ฏ๋ก ๋๊ตฌ๋ ์ ๊ทผํด๋ ๊ด์ฐฎ์ต๋๋ค.
ํ
์ด๋ธ ์์ฑ
๊ฐ์ธ ํค์ ๊ณต๊ฐ ํค ์์ ์ ์ฅํ keys ํ
์ด๋ธ์ src/schema.sql ์ ์ ์ํฉ๋๋ค:
CREATE TABLE IF NOT EXISTS keys (
user_id INTEGER NOT NULL REFERENCES users (id),
type TEXT NOT NULL CHECK ( type IN ( ' RSASSA-PKCS1-v1_5 ' , ' Ed25519 ' )),
private_key TEXT NOT NULL CHECK (private_key <> '' ),
public_key TEXT NOT NULL CHECK (public_key <> '' ),
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '' ),
PRIMARY KEY (user_id, type )
);
ํ
์ด๋ธ์ ์ ์ฌํ ์ดํด๋ณด๋ฉด, type ์นผ๋ผ์๋ ์ค์ง ๋ ์ข
๋ฅ์ ๊ฐ๋ง ํ์ฉ๋๋ค๋ ๊ฒ์ ์ ์ ์์ต๋๋ค. ํ๋๋ RSA-PKCS#1-v1.5 ํ์์ด๊ณ ๋ค๋ฅธ ํ๋๋ Ed25519 ํ์์
๋๋ค. (๊ฐ๊ฐ์ด ๋ฌด์์ ๋ปํ๋์ง๋ ์ด ํํ ๋ฆฌ์ผ์์ ์ค์ํ์ง ์์ต๋๋ค.) ๊ธฐ๋ณธ ํค๊ฐ (user_id, type)์ ๊ฑธ๋ ค ์์ผ๋, ํ ์ฌ์ฉ์์ ๋ํด ์ต๋ ๋ ์์ ํค๊ฐ ์์ ์ ์์ต๋๋ค.
ํ
์ด ํํ ๋ฆฌ์ผ์์ ์์ธํ ์ค๋ช
ํ ์๋ ์์ง๋ง, 2024๋
9์ ํ์ฌ ActivityPub ๋คํธ์ํฌ๋ RSA-PKCS# 1 -v1.5 ํ์์์ Ed25519 ํ์์ผ๋ก ์ดํํ๊ณ ์๋ ์ค์ด๋ผ๊ณ ์๊ณ ๊ณ์๋ฉด ์ข์ต๋๋ค. ์ด๋ค ์ํํธ์จ์ด๋ RSA-PKCS# 1 -v1.5 ํ์๋ง ๋ฐ์๋ค์ด๊ณ ์ด๋ค ์ํํธ์จ์ด๋ Ed25519 ํ์์ ๋ฐ์๋ค์
๋๋ค. ๋ฐ๋ผ์, ์์ชฝ ๋ชจ๋์ ์ํตํ๊ธฐ ์ํด์๋ ๋ ์์ ํค๊ฐ ๋ชจ๋ ํ์ํ ๊ฒ์
๋๋ค.
private_key ๋ฐ public_key ์นผ๋ผ์ ๋ฌธ์์ด์ ๋ฐ์ ์ ์๊ฒ ๋์ด ์๋๋ฐ, ์ฐ๋ฆฌ๋ ์ฌ๊ธฐ์ JSON ๋ฐ์ดํฐ๋ฅผ ๋ฃ์ ์์ ์
๋๋ค. ๊ฐ์ธ ํค์ ๊ณต๊ฐ ํค๋ฅผ JSON์ผ๋ก ์ธ์ฝ๋ฉํ๋ ๋ฐฉ๋ฒ์ ๋ํด์๋ ๋ค์์ ์ฐจ์ฐจ ๋ค๋ฃจ๊ฒ ๋ ๊ฒ์
๋๋ค.
๊ทธ๋ผ keys ํ
์ด๋ธ์ ์์ฑํฉ์๋ค:
sqlite3 microblog.sqlite3 < src/schema.sql
keys ํ
์ด๋ธ์ ์ ์ฅ๋๋ ๋ ์ฝ๋๋ฅผ JavaScript๋ก ํํํ Key ํ์
๋ src/schema.ts ํ์ผ์ ์ ์ํฉ๋๋ค:
export interface Key {
user_id : number ;
type : " RSASSA-PKCS1-v1_5 " | " Ed25519 " ;
private_key : string ;
public_key : string ;
created : string ;
}
ํค ์ ๋์คํจ์ฒ
์ด์ ํค ์์ ์์ฑํ๊ณ ๋ถ๋ฌ์ค๋ ์ฝ๋๋ฅผ ์ง์ผ ํฉ๋๋ค.
src/federation.ts ํ์ผ์ ์ด๊ณ Fedify์์ ์ ๊ณต๋๋ exportJwk(), generateCryptoKeyPair(), importJwk() ํจ์๋ค๊ณผ ์์ ์ ์ํ Key ํ์
์ importํฉ์๋ค:
import {
Endpoints ,
Person ,
createFederation ,
exportJwk ,
generateCryptoKeyPair ,
importJwk ,
} from " @fedify/fedify " ;
import type { Actor , Key , User } from " ./schema.ts " ;
๊ทธ๋ฆฌ๊ณ ์กํฐ ๋์คํจ์ฒ ๋ถ๋ถ์ ๋ค์๊ณผ ๊ฐ์ด ๊ณ ์นฉ๋๋ค:
federation
. setActorDispatcher ( " /users/{identifier} " , async ( ctx , identifier ) => {
const user = db
. prepare < unknown [], User & Actor >(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
WHERE users.username = ?
` ,
)
. get ( identifier );
if ( user == null ) return null ;
const keys = await ctx . getActorKeyPairs ( identifier );
return new Person ({
id : ctx . getActorUri ( identifier ),
preferredUsername : identifier ,
name : user . name ,
inbox : ctx . getInboxUri ( identifier ),
endpoints : new Endpoints ({
sharedInbox : ctx . getInboxUri (),
}),
url : ctx . getActorUri ( identifier ),
publicKey : keys [ 0 ]. cryptographicKey ,
assertionMethods : keys . map (( k ) => k . multikey ),
});
})
. setKeyPairsDispatcher ( async ( ctx , identifier ) => {
const user = db
. prepare < unknown [], User >( " SELECT * FROM users WHERE username = ? " )
. get ( identifier );
if ( user == null ) return [];
const rows = db
. prepare < unknown [], Key >( " SELECT * FROM keys WHERE keys.user_id = ? " )
. all ( user . id );
const keys = Object . fromEntries (
rows . map (( row ) => [ row . type , row ]),
) as Record < Key [ " type " ], Key >;
const pairs : CryptoKeyPair [] = [];
// ์ฌ์ฉ์๊ฐ ์ง์ํ๋ ๋ ํค ํ์ (RSASSA-PKCS1-v1_5 ๋ฐ Ed25519) ๊ฐ๊ฐ์ ๋ํด
// ํค ์์ ๋ณด์ ํ๊ณ ์๋์ง ํ์ธํ๊ณ , ์์ผ๋ฉด ์์ฑ ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ:
for ( const keyType of [ " RSASSA-PKCS1-v1_5 " , " Ed25519 " ] as const ) {
if ( keys [ keyType ] == null ) {
logger . debug (
" The user {identifier} does not have an {keyType} key; creating one... " ,
{ identifier , keyType },
);
const { privateKey , publicKey } = await generateCryptoKeyPair ( keyType );
db . prepare (
`
INSERT INTO keys (user_id, type, private_key, public_key)
VALUES (?, ?, ?, ?)
` ,
). run (
user . id ,
keyType ,
JSON . stringify ( await exportJwk ( privateKey )),
JSON . stringify ( await exportJwk ( publicKey )),
);
pairs . push ({ privateKey , publicKey });
} else {
pairs . push ({
privateKey : await importJwk (
JSON . parse ( keys [ keyType ]. private_key ),
" private " ,
),
publicKey : await importJwk (
JSON . parse ( keys [ keyType ]. public_key ),
" public " ,
),
});
}
}
return pairs ;
});
์ฐ์ ๊ฐ์ฅ ๋จผ์ ์ฃผ๋ชฉํด์ผ ํ ๊ฒ์ setActorDispatcher() ๋ฉ์๋์ ์ฐ๋ฌ์ ํธ์ถ๋๊ณ ์๋ setKeyPairsDispatcher() ๋ฉ์๋์
๋๋ค. ์ด ๋ฉ์๋๋ ์ฝ๋ฐฑ ํจ์์์ ๋ฐํ๋ ํค ์๋ค์ ๊ณ์ ์ ์ฐ๊ฒฐํ๋ ์ญํ ์ ํฉ๋๋ค. ์ด๋ ๊ฒ ํค ์๋ค์ ์ฐ๊ฒฐํด์ผ Fedify๊ฐ ์กํฐ๋นํฐ๋ฅผ ๋ฐ์ ํ ๋ ์๋์ผ๋ก ๋ฑ๋ก๋ ๊ฐ์ธ ํค๋ค๋ก ๋์งํธ ์๋ช
์ ์ถ๊ฐํฉ๋๋ค.
generateCryptoKeyPair() ํจ์๋ ์๋ก์ด ๊ฐ์ธ ํค ๋ฐ ๊ณต๊ฐ ํค ์์ ์์ฑํ์ฌ CryptoKeyPair ๊ฐ์ฒด๋ก ๋ฐํํฉ๋๋ค. ์ฐธ๊ณ ๋ก CryptoKeyPair ํ์
์ { privateKey: CryptoKey; publicKey: CryptoKey; } ํ์์
๋๋ค.
exportJwk() ํจ์๋ CryptoKey ๊ฐ์ฒด๋ฅผ JWK ํ์์ผ๋ก ํํํ ๊ฐ์ฒด๋ฅผ ๋ฐํํฉ๋๋ค. JWK ํ์์ด ๋ฌด์์ธ์ง ์ ํ์๋ ์์ต๋๋ค. ๊ทธ์ ์ํธ ํค๋ฅผ JSON์ผ๋ก ํํํ๋ ํ์ค์ ์ธ ํ์์ด๋ผ๊ณ ์ดํดํ์๋ฉด ์ถฉ๋ถํฉ๋๋ค. CryptoKey๋ ์ํธ ํค๋ฅผ JavaScript ๊ฐ์ฒด๋ก ํํํ๊ธฐ ์ํ ์น ํ์ค ํ์
์
๋๋ค.
importJwk() ํจ์๋ JWK ํ์์ผ๋ก ํํ๋ ํค๋ฅผ CryptoKey ๊ฐ์ฒด๋ก ๋ณํํฉ๋๋ค. exportJwk() ํจ์์ ๋ฐ๋๋ผ๊ณ ์ดํดํ์๋ฉด ๋ฉ๋๋ค.
์, ๊ทธ๋ผ ์ด์ ๋ค์ setActorDispatcher() ๋ฉ์๋๋ก ๋์ ๋๋ฆฝ์๋ค. getActorKeyPairs()๋ผ๋ ๋ฉ์๋๊ฐ ์ฐ์ด๊ณ ์๋๋ฐ, ์ด ๋ฉ์๋๋ ์ด๋ฆ๊ณผ ๊ฐ์ด ์กํฐ์ ํค ์๋ค์ ๋ฐํํฉ๋๋ค. ์กํฐ์ ํค ์๋ค์ ๋ฐ๋ก ์์์ ์ดํด๋ณธ setKeyPairsDispatcher() ๋ฉ์๋๋ก ๋ถ๋ฌ์จ ๋ฐ๋ก ๊ทธ ํค ์๋ค์
๋๋ค. ์ฐ๋ฆฌ๋ RSA-PKCS# 1 -v1.5์ Ed25519 ํ์์ผ๋ก ๋ ๋ ์์ ํค๋ฅผ ๋ถ๋ฌ์์ผ๋ฏ๋ก, getActorKeyPairs() ๋ฉ์๋๋ ๋ ํค ์์ ๋ฐฐ์ด์ ๋ฐํํฉ๋๋ค. ๊ฐ ๋ฐฐ์ด์ ์์๋ ํค ์์ ์ฌ๋ฌ ํ์์ผ๋ก ํํํ ๊ฐ์ฒด์ธ๋ฐ, ๋ค์๊ณผ ๊ฐ์ด ์๊ฒผ์ต๋๋ค:
interface ActorKeyPair {
privateKey : CryptoKey ; // ๊ฐ์ธ ํค
publicKey : CryptoKey ; // ๊ณต๊ฐ ํค
keyId : URL ; // ํค์ ๊ณ ์ ์๋ณ URI
cryptographicKey : CryptographicKey ; // ๊ณต๊ฐ ํค์ ๋ค๋ฅธ ํ์
multikey : Multikey ; // ๊ณต๊ฐ ํค์ ๋ ๋ค๋ฅธ ํ์
}
CryptoKey์ CryptographicKey์ Multikey๊ฐ ๊ฐ๊ฐ ์ด๋ป๊ฒ ๋ค๋ฅธ์ง, ์ ์ด๋ ๊ฒ ์ฌ๋ฌ ํ์์ด ์์ด์ผ ํ๋์ง๋ ์ด ์๋ฆฌ์์ ์ค๋ช
ํ๊ธฐ์ ๋ณต์กํฉ๋๋ค. ๋ค๋ง ์ง๊ธ์ Person ๊ฐ์ฒด๋ฅผ ์ด๊ธฐํํ ๋ publicKey ์์ฑ์ CryptographicKey ํ์์ ๋ฐ๊ณ assertionMethods ์์ฑ์ MultiKey[] (Multikey์ ๋ฐฐ์ด์ TypeScript์์ ์ด๋ ๊ฒ ํ๊ธฐ) ํ์์ ๋ฐ๋๋ค๋ ๊ฒ๋ง ์ง๊ณ ๋์ด๊ฐ๋๋ก ํฉ์๋ค.
๊ทธ๋์ ๋, Person ๊ฐ์ฒด์๋ ์ ๊ณต๊ฐ ํค๋ฅผ ๊ฐ๋ ์์ฑ์ด publicKey์ assertionMethods๋ก ๋ ๊ฐ๋ ์์๊น์? ActivityPub์๋ ์๋ publicKey ์์ฑ๋ง ์์์ง๋ง, ๋์ค์ ์ฌ๋ฌ ํค๋ฅผ ๋ฑ๋กํ ์ ์๋๋ก assertionMethods ์์ฑ์ด ์ถ๊ฐ๋์์ต๋๋ค. ์์ RSA-PKCS# 1 -v1.5 ํ์๊ณผ Ed25519 ํ์์ ํค๋ฅผ ๋ชจ๋ ์์ฑํ๋ ๊ฒ๊ณผ ๋น์ทํ ์ด์ ๋ก, ์ฌ๋ฌ ์ํํธ์จ์ด์์ ํธํ์ฑ์ ์ํด ๋ ์์ฑ ๋ชจ๋ ์ค์ ํ๋ ๊ฒ์
๋๋ค. ์์ธํ ๋ณด๋ฉด, ๋ ๊ฑฐ์ ์์ฑ์ธ publicKey์๋ ๋ ๊ฑฐ์ ํค ํ์์ธ RSA-PKCS# 1 -v1.5 ํค๋ง ๋ฑ๋กํ๊ณ ์๋ค๋ ๊ฒ์ ์ ์ ์์ต๋๋ค (๋ฐฐ์ด์ ์ฒซ ๋ฒ์งธ ํญ๋ชฉ์ RSA-PKCS# 1 -v1.5 ํค ์์ด, ๋ ๋ฒ์งธ ํญ๋ชฉ์ Ed25519 ํค ์์ด ๋ค์ด๊ฐ).
ํ
์ฌ์ค publicKey ์์ฑ๋ ์ฌ๋ฌ ํค๋ฅผ ๋ด์ ์๋ ์์ต๋๋ค. ๊ทธ๋ ์ง๋ง ๋ง์ ์ํํธ์จ์ด๋ค์ด ์ด๋ฏธ publicKey ์์ฑ์๋ ๋จ ํ๋์ ํค๋ง ๋ค์ด๊ฐ ๊ฒ์ด๋ผ๋ ์ ์ ํ์ ๊ตฌํ๋์๊ธฐ ๋๋ฌธ์ ์ค์๋ํ ๋๊ฐ ๋ง์ต๋๋ค. ์ด๋ฅผ ํผํ๊ธฐ ์ํด assertionMethods๋ผ๋ ์๋ก์ด ์์ฑ์ด ์ ์๋ ๊ฒ์
๋๋ค.
์ด์ ๊ดํด ๊ด์ฌ์ด ์๊ธฐ์ ๋ถ๋ค์ FEP-521a ๋ฌธ์๋ฅผ ์ฐธ๊ณ ํ์ธ์.
ํ
์คํธ
์, ์กํฐ ๊ฐ์ฒด์ ์ํธ ํค๋ค์ ๋ฑ๋กํ์ผ๋ฏ๋ก ์ ๋์ํ๋์ง ํ์ธํ๋๋ก ํฉ์๋ค. ๋ค์ ๋ช
๋ น์ผ๋ก ์กํฐ๋ฅผ ์กฐํํฉ๋๋ค.
fedify lookup http://localhost:8000/users/johndoe
์ ์์ ์ผ๋ก ๋์ํ๋ค๋ฉด ์๋์ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ์ถ๋ ฅ๋ฉ๋๋ค:
โ Looking up the object...
Person {
id: URL "http://localhost:8000/users/johndoe",
name: "John Doe",
url: URL "http://localhost:8000/users/johndoe",
preferredUsername: "johndoe",
publicKey: CryptographicKey {
id: URL "http://localhost:8000/users/johndoe#main-key",
owner: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: {
name: "RSASSA-PKCS1-v1_5",
modulusLength: 4096,
publicExponent: Uint8Array(3) [ 1, 0, 1 ],
hash: { name: "SHA-256" }
},
usages: [ "verify" ]
}
},
assertionMethods: [
Multikey {
id: URL "http://localhost:8000/users/johndoe#main-key",
controller: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: {
name: "RSASSA-PKCS1-v1_5",
modulusLength: 4096,
publicExponent: Uint8Array(3) [ 1, 0, 1 ],
hash: { name: "SHA-256" }
},
usages: [ "verify" ]
}
},
Multikey {
id: URL "http://localhost:8000/users/johndoe#key-2",
controller: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: { name: "Ed25519" },
usages: [ "verify" ]
}
}
],
inbox: URL "http://localhost:8000/users/johndoe/inbox",
endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}
Person ๊ฐ์ฒด์ publicKey ์์ฑ์๋ RSA-PKCS# 1 -v1.5 ํ์์ CryptographicKey ๊ฐ์ฒด ํ๋๊ฐ, assertionMethods ์์ฑ์๋ RSA-PKCS# 1 -v1.5 ํ์๊ณผ Ed25519 ํ์์ Multikey ๊ฐ์ฒด๊ฐ ๋ ๋ค์ด์๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค.
Mastodon๊ณผ ์ฐ๋
์ด์ ์ค์ Mastodon์์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ฅผ ๋ณผ ์ ์๋์ง ํ์ธํด ๋ด
์๋ค.
๊ณต๊ฐ ์ธํฐ๋ท์ ๋
ธ์ถ
์์ฝ๊ฒ๋ ํ์ฌ ์๋ฒ๋ ๋ก์ปฌ์์๋ง ์ ๊ทผ์ด ๊ฐ๋ฅํฉ๋๋ค. ํ์ง๋ง ์ฝ๋๋ฅผ ์์ ํ ๋๋ง๋ค ์ด๋๊ฐ์ ๋ฐฐํฌํด์ ํ
์คํธํ๋ ๊ฒ์ ๋ถํธํ๊ฒ ์ฃ . ๋ฐฐํฌํ์ง ์๊ณ ๋ฐ๋ก ์ธํฐ๋ท์ ๋ก์ปฌ ์๋ฒ๋ฅผ ๋
ธ์ถํ์ฌ ํ
์คํธํด ๋ณผ ์ ์๋ค๋ฉด ์ผ๋ง๋ ์ข์๊น์?
์ฌ๊ธฐ, fedify tunnel์ด ๊ทธ๋ด ๋ ์ฐ๋ ๋ช
๋ น์ด์
๋๋ค. ํฐ๋ฏธ๋์์ ์ ํญ์ ์ฐ ๋ค, ์ด ๋ช
๋ น์ด ๋ค์ ๋ก์ปฌ ์๋ฒ์ ํฌํธ ๋ฒํธ๋ฅผ ์
๋ ฅํ๋ฉด ๋ฉ๋๋ค:
fedify tunnel 8000
๊ทธ๋ฌ๋ฉด ํ ๋ฒ ์ฐ๊ณ ๋ฒ๋ฆด ๋๋ฉ์ธ ์ด๋ฆ์ ๋ง๋ค์ด์ ๋ก์ปฌ ์๋ฒ๋ก ์ค๊ณ๋ฅผ ํฉ๋๋ค. ์ธ๋ถ์์๋ ์ ๊ทผํ ์ ์๋ URL์ด ์ถ๋ ฅ๋ ๊ฒ์
๋๋ค:
โ Your local server at 8000 is now publicly accessible:
https://temp-address.serveo.net/
Press ^C to close the tunnel.
๋ฌผ๋ก , ์ฌ๋ฌ๋ถ์๊ฒ๋ ์ URL๊ณผ๋ ๋ค๋ฅธ ์ฌ๋ฌ๋ถ๋ง์ ๊ณ ์ ํ URL์ด ์ถ๋ ฅ๋์์ ๊ฒ์
๋๋ค. ์น ๋ธ๋ผ์ฐ์ ์์ https://temp-address.serveo.net/users/johndoe (์ฌ๋ฌ๋ถ์ ๊ณ ์ ์์ ๋๋ฉ์ธ์ผ๋ก ์นํํ์ธ์)๋ฅผ ์ด์ด์ ์ ์ ์๋๋์ง ํ์ธํ ์ ์์ต๋๋ค:
์ ์น ํ์ด์ง์ ๋ณด์ด๋ ์ฌ๋ฌ๋ถ์ ์ฐํฉ์ฐ์ฃผ ํธ๋ค์ ๋ณต์ฌํ ๋ค, Mastodon์ ๋ค์ด๊ฐ ์ข์๋จ์ ์์นํ ๊ฒ์์ฐฝ์ ๋ถ์ฌ๋ฃ๊ณ ๊ฒ์์ ํด ๋ณด์ธ์:
์์ ๊ฐ์ด ๊ฒ์ ๊ฒฐ๊ณผ์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๊ฐ ๋ณด์ด๋ฉด ์ ์์
๋๋ค. ๊ฒ์ ๊ฒฐ๊ณผ์์ ์กํฐ์ ์ด๋ฆ์ ๋๋ฌ์ ํ๋กํ ํ์ด์ง๋ก ๋ค์ด๊ฐ ์๋ ์์ต๋๋ค:
ํ์ง๋ง ์ฌ๊ธฐ๊น์ง์
๋๋ค. ์์ง ํ๋ก๋ ํ ์ ์์ผ๋ ์๋ํ์ง ๋ง์ธ์! ๋ค๋ฅธ ์๋ฒ์์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ฅผ ํ๋กํ ์ ์์ผ๋ ค๋ฉด, ์์ ํจ์ ๊ตฌํํด์ผ ํฉ๋๋ค.
์๋ด
fedify tunnel ๋ช
๋ น์ ํ๋์ ์ฐ์ด์ง ์์ผ๋ฉด ์ ์ ๋ก ์ฐ๊ฒฐ์ด ๋๊น๋๋ค. ๊ทธ๋ด ๋๋, Ctrl +C ํค๋ฅผ ๋๋ฌ ๋ ๋ค์, fedify tunnel 8000 ๋ช
๋ น์ ๋ค์ ์ณ์ ์๋ก์ด ์ฐ๊ฒฐ์ ๋งบ์ด์ผ ํฉ๋๋ค.
์์ ํจ
ActivityPub์์ ์์ ํจ(inbox)์ ์กํฐ๊ฐ ๋ค๋ฅธ ์กํฐ๋ก๋ถํฐ ์กํฐ๋นํฐ๋ฅผ ๋ฐ๋ ์๋ํฌ์ธํธ์
๋๋ค. ๋ชจ๋ ์กํฐ๋ ์์ ์ ์์ ํจ์ ๊ฐ์ง๊ณ ์์ผ๋ฉฐ, ์ด๋ HTTP POST ์์ฒญ์ ํตํด ์กํฐ๋นํฐ๋ฅผ ๋ฐ์ ์ ์๋ URL์
๋๋ค. ๋ค๋ฅธ ์กํฐ๊ฐ ํ๋ก ์์ฒญ์ ๋ณด๋ด๊ฑฐ๋, ๊ธ์ ์ฐ๊ฑฐ๋, ๋๊ธ์ ๋ค๋ ๋ฑ์ ์ํธ์์ฉ์ ํ ๋ ํด๋น ์กํฐ๋นํฐ๋ ์์ ์์ ์์ ํจ์ผ๋ก ์ ๋ฌ๋ฉ๋๋ค. ์๋ฒ๋ ์์ ํจ์ผ๋ก ๋ค์ด์จ ์กํฐ๋นํฐ๋ฅผ ์ฒ๋ฆฌํ๊ณ ์ ์ ํ ์๋ตํจ์ผ๋ก์จ ๋ค๋ฅธ ์กํฐ๋ค๊ณผ ์ํตํ๊ณ ์ฐํฉ ๋คํธ์ํฌ์ ์ผ๋ถ๋ก ๊ธฐ๋ฅํ๊ฒ ๋ฉ๋๋ค.
์์ ํจ์ ์ฌ๋ฌ ์ข
๋ฅ์ ์กํฐ๋นํฐ๋ฅผ ์์ ํ ์ ์์ง๋ง, ์ง๊ธ์ ํ๋ก ์์ฒญ์ ๋ฐ๋ ๊ฒ๋ถํฐ ๊ตฌํํ๊ฒ ์ต๋๋ค.
ํ
์ด๋ธ ์์ฑ
์์ ์ ํ๋กํ๋ ์กํฐ๋ค(ํ๋ก์)๊ณผ ์์ ์ด ํ๋กํ๋ ์กํฐ๋ค(ํ๋ก์)์ ๋ด๊ธฐ ์ํด src/schema.sql ํ์ผ์ follows ํ
์ด๋ธ์ ์ ์ํฉ๋๋ค:
CREATE TABLE IF NOT EXISTS follows (
following_id INTEGER REFERENCES actors (id),
follower_id INTEGER REFERENCES actors (id),
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
CHECK (created <> '' ),
PRIMARY KEY (following_id, follower_id)
);
์ด๋ฒ์๋ src/schema.sql ์ ์คํํ์ฌ follows ํ
์ด๋ธ์ ์์ฑํฉ์๋ค:
sqlite3 microblog.sqlite3 < src/schema.sql
src/schema.ts ํ์ผ์ ์ด๊ณ follows ํ
์ด๋ธ์ ์ ์ฅ๋๋ ๋ ์ฝ๋๋ฅผ JavaScript์์ ํํํ๊ธฐ ์ํ ํ์
๋ ์ ์ํฉ๋๋ค:
export interface Follow {
following_id : number ;
follower_id : number ;
created : string ;
}
Follow ์กํฐ๋นํฐ ์์
์ด์ ์์ ํจ์ ๊ตฌํํ ์ฐจ๋ก์
๋๋ค. ์ค์ ์์ ์ด๋ฏธ src/federation.ts ํ์ผ์ ๋ค์๊ณผ ๊ฐ์ ์ฝ๋๋ฅผ ์์ฑํ ๋ฐ ์์ต๋๋ค:
federation . setInboxListeners ( " /users/{identifier}/inbox " , " /inbox " );
์ ์ฝ๋๋ฅผ ์์ ํ๊ธฐ์ ์์, Fedify๊ฐ ์ ๊ณตํ๋ Accept ๋ฐ Follow ํด๋์ค์ getActorHandle() ํจ์๋ฅผ importํฉ๋๋ค:
import {
Accept ,
Endpoints ,
Follow ,
Person ,
createFederation ,
exportJwk ,
generateCryptoKeyPair ,
getActorHandle ,
importJwk ,
} from " @fedify/fedify " ;
๊ทธ๋ฆฌ๊ณ setInboxListeners() ๋ฉ์๋๋ฅผ ํธ์ถํ๋ ์ฝ๋๋ฅผ ์๋์ ๊ฐ์ด ๊ณ ์นฉ๋๋ค:
federation
. setInboxListeners ( " /users/{identifier}/inbox " , " /inbox " )
. on ( Follow , async ( ctx , follow ) => {
if ( follow . objectId == null ) {
logger . debug ( " The Follow object does not have an object: {follow} " , {
follow ,
});
return ;
}
const object = ctx . parseUri ( follow . objectId );
if ( object == null || object . type !== " actor " ) {
logger . debug ( " The Follow object's object is not an actor: {follow} " , {
follow ,
});
return ;
}
const follower = await follow . getActor ();
if ( follower ?. id == null || follower . inboxId == null ) {
logger . debug ( " The Follow object does not have an actor: {follow} " , {
follow ,
});
return ;
}
const followingId = db
. prepare < unknown [], Actor >(
`
SELECT * FROM actors
JOIN users ON users.id = actors.user_id
WHERE users.username = ?
` ,
)
. get ( object . identifier )?. id ;
if ( followingId == null ) {
logger . debug (
" Failed to find the actor to follow in the database: {object} " ,
{ object },
);
return ;
}
const followerId = db
. prepare < unknown [], Actor >(
`
-- ํ๋ก์ ์กํฐ ๋ ์ฝ๋๋ฅผ ์๋ก ์ถ๊ฐํ๊ฑฐ๋ ์ด๋ฏธ ์์ผ๋ฉด ๊ฐฑ์
INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (uri) DO UPDATE SET
handle = excluded.handle,
name = excluded.name,
inbox_url = excluded.inbox_url,
shared_inbox_url = excluded.shared_inbox_url,
url = excluded.url
WHERE
actors.uri = excluded.uri
RETURNING *
` ,
)
. get (
follower . id . href ,
await getActorHandle ( follower ),
follower . name ?. toString (),
follower . inboxId . href ,
follower . endpoints ?. sharedInbox ?. href ,
follower . url ?. href ,
)?. id ;
db . prepare (
" INSERT INTO follows (following_id, follower_id) VALUES (?, ?) " ,
). run ( followingId , followerId );
const accept = new Accept ({
actor : follow . objectId ,
to : follow . actorId ,
object : follow ,
});
await ctx . sendActivity ( object , follower , accept );
});
์, ์ฝ๋๋ฅผ ์ฐฌ์ฐฌํ ์ดํด๋ด
์๋ค. on() ๋ฉ์๋๋ ํน์ ํ ์ข
๋ฅ์ ์กํฐ๋นํฐ๊ฐ ์์ ๋์์ ๋ ์ทจํ ํ๋์ ์ ์ํฉ๋๋ค. ์ฌ๊ธฐ์๋ ํ๋ก ์์ฒญ์ ๋ปํ๋ Follow ์กํฐ๋นํฐ๊ฐ ์์ ๋์์ ๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ํ๋ก์ ์ ๋ณด๋ฅผ ๊ธฐ๋กํ ๋ค, ํ๋ก ์์ฒญ์ ๋ณด๋ธ ์กํฐ์๊ฒ ์๋ฝ์ ๋ปํ๋ Accept(Follow) ์กํฐ๋นํฐ๋ฅผ ๋ต์ฅ์ผ๋ก ๋ณด๋ด๋ ์ฝ๋๋ฅผ ์์ฑํ์ต๋๋ค.
follow.objectId์๋ ํ๋ก ๋์์ธ ์กํฐ์ URI๊ฐ ๋ค์ด ์์ด์ผ ํฉ๋๋ค. parseUri() ๋ฉ์๋๋ฅผ ํตํด ์ด ์์ ๋ URI๊ฐ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ฅผ ๊ฐ๋ฆฌํค๋์ง ํ์ธํฉ๋๋ค.
getActorHandle() ํจ์๋ ์ฃผ์ด์ง ์กํฐ ๊ฐ์ฒด๋ก๋ถํฐ ์ฐํฉ์ฐ์ฃผ ํธ๋ค์ ๊ตฌํ์ฌ ๋ฌธ์์ด์ ๋ฐํํฉ๋๋ค.
ํ๋ก ์์ฒญ์ ๋ณด๋ธ ์กํฐ์ ๋ํ ์ ๋ณด๊ฐ actors ํ
์ด๋ธ์ ์์ง ์๋ค๋ฉด ๋จผ์ ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํฉ๋๋ค. ์ด๋ฏธ ๋ ์ฝ๋๊ฐ ์๋ค๋ฉด ์ต์ ๋ฐ์ดํฐ๋ก ๊ฐฑ์ ํฉ๋๋ค. ๊ทธ ๋ค, follows ํ
์ด๋ธ์ ํ๋ก์๋ฅผ ์ถ๊ฐํฉ๋๋ค.
๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๊ธฐ๋ก์ด ์๋ฃ๋๋ฉด, sendActivity() ๋ฉ์๋๋ฅผ ์ด์ฉํด ์กํฐ๋นํฐ๋ฅผ ๋ณด๋ธ ์กํฐ์๊ฒ Accept(Follow) ์กํฐ๋นํฐ๋ฅผ ๋ต์ฅ์ผ๋ก ๋ณด๋
๋๋ค. ์ฒซ์งธ ํ๋ผ๋ฏธํฐ๋ก ๋ฐ์ ์, ๋์งธ ํ๋ผ๋ฏธํฐ๋ก ์์ ์, ์
์งธ ํ๋ผ๋ฏธํฐ๋ก ๋ณด๋ผ ์กํฐ๋นํฐ ๊ฐ์ฒด๋ฅผ ๋ฐ์ต๋๋ค.
ActivityPub.Academy
์, ๊ทธ๋ผ ํ๋ก ์์ฒญ์ด ์ ๋๋ก ์์ ๋๋์ง ํ์ธํ ์ฐจ๋ก์
๋๋ค.
๋ณดํต์ Mastodon ์๋ฒ์์ ํ
์คํธ๋ฅผ ํด๋ ๊ด์ฐฎ๊ธด ํ์ง๋ง, ์กํฐ๋นํฐ๊ฐ ๊ตฌ์ฒด์ ์ผ๋ก ์ด๋ป๊ฒ ์ค๊ฐ๋์ง ํ์ธํ ์ ์๋ ActivityPub.Academy ์๋ฒ๋ฅผ ์ด์ฉํ๋๋ก ํฉ๋๋ค. ActivityPub.Academy๋ ๊ต์ก ๋ฐ ๋๋ฒ๊น
์ฉ๋์ ํน์ํ Mastodon ์๋ฒ์ธ๋ฐ, ํด๋ฆญ ํ ๋ฒ์ผ๋ก ์์ ๊ณ์ ์ ์ฝ๊ฒ ๋ง๋ค ์ ์์ต๋๋ค.
๊ฐ์ธ ์ ๋ณด ๋ณดํธ ์ ์ฑ
์ ๋์ํ ๋ค ๋ฑ๋กํ๊ธฐ ๋ฒํผ์ ๋๋ฌ ์ ๊ณ์ ์ ์์ฑํฉ๋๋ค. ์์ฑ๋ ๊ณ์ ์ ๋ฌด์์๋ก ์ง์ด์ง ์ด๋ฆ๊ณผ ํธ๋ค์ ๊ฐ๊ฒ ๋๋ฉฐ, ํ๋ฃจ๊ฐ ์ง๋๋ฉด ์์์ ์ฌ๋ผ์ง๋๋ค. ๋์ , ๊ณ์ ์ ๋ ์๋ก ์์ฑํ ์ ์์ต๋๋ค.
๋ก๊ทธ์ธ์ด ๋๊ณ ๋๋ฉด ํ๋ฉด์ ์ข์๋จ์ ์์นํ ๊ฒ์์ฐฝ์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ์ ํธ๋ค์ ๋ถ์ฌ๋ฃ๊ณ ๊ฒ์ํฉ๋๋ค:
์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๊ฐ ๊ฒ์ ๊ฒฐ๊ณผ์ ํ์๋๋ฉด, ์ค๋ฅธ์ชฝ์ ์๋ ํ๋ก ๋ฒํผ์ ๋๋ฌ์ ํ๋ก ์์ฒญ์ ๋ณด๋
๋๋ค. ๊ทธ๋ฆฌ๊ณ ์ฐ์ธก ๋ฉ๋ด์์ Activity Log ๋ฅผ ๋๋ฆ
๋๋ค:
๊ทธ๋ผ ๋ฐฉ๊ธ ํ๋ก ๋ฒํผ์ ๋๋ฆ์ผ๋ก์จ ActivityPub.Academy ์๋ฒ์์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ์ ์์ ํจ์ผ๋ก Follow ์กํฐ๋นํฐ๊ฐ ์ ์ก๋์๋ค๋ ํ์๊ฐ ๋ณด์
๋๋ค. ์ฐํ๋จ์ show source ๋ฅผ ๋๋ฅด๋ฉด ์กํฐ๋นํฐ์ ๋ด์ฉ๊น์ง ๋ณผ ์ ์์ต๋๋ค:
ํ
์คํธ
์กํฐ๋นํฐ๊ฐ ์ ์ ์ก๋์๋ค๋ ๊ฑธ ํ์ธํ์ผ๋, ์ค์ ๋ก ์ ํฌ๊ฐ ์ง ์์ ํจ ์ฝ๋๊ฐ ์ ๋์ํ๋์ง ํ์ธํ ์ฐจ๋ก์
๋๋ค. ๋จผ์ follows ํ
์ด๋ธ์ ๋ ์ฝ๋๊ฐ ์ ๋ง๋ค์ด์ก๋์ง ๋ด
์๋ค:
echo " SELECT * FROM follows; " | sqlite3 -table microblog.sqlite3
ํ๋ก ์์ฒญ์ด ์ ์ฒ๋ฆฌ๋์๋ค๋ฉด, ๋ค์๊ณผ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ๋์ต๋๋ค (๋ฌผ๋ก , ์๊ฐ์ ๋ค๋ฅด๊ฒ ์ฃ ?):
following_id
follower_id
created
1
2
2024-09-01 10:19:41
๊ณผ์ฐ actors ํ
์ด๋ธ์๋ ์ ๋ ์ฝ๋๊ฐ ์๊ฒผ๋์ง ํ์ธํฉ์๋ค:
echo " SELECT * FROM actors WHERE id > 1; " | sqlite3 -table microblog.sqlite3
id
user_id
uri
handle
name
inbox_url
shared_inbox_url
url
created
2
https://activitypub.academy/users/dobussia_dovornath
@dobussia_dovornath@activitypub.academy
Dobussia Dovornath
https://activitypub.academy/users/dobussia_dovornath/inbox
https://activitypub.academy/inbox
https://activitypub.academy/@dobussia_dovornath
2024-09-01 10:19:41
๋ค์, ActivityPub.Academy์ Activity Log ๋ฅผ ๋ด
์๋ค. ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ์์ ๋ณด๋ธ Accept(Follow) ์กํฐ๋นํฐ๊ฐ ์ ๋์ฐฉํ๋ค๋ฉด, ์๋์ ๊ฐ์ด ํ์๋ ๊ฒ์
๋๋ค:
์, ์ด๋ ๊ฒ ์ฌ๋ฌ๋ถ์ ์ฒ์์ผ๋ก ActivityPub์ ํตํ ์ํธ์์ฉ์ ๊ตฌํํด๋์ต๋๋ค!
ํ๋ก ์ทจ์
๋ค๋ฅธ ์๋ฒ์ ์กํฐ๊ฐ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ฅผ ํ๋กํ๋ค๊ฐ ๋ค์ ์ทจ์ํ๋ฉด ์ด๋ป๊ฒ ๋ ๊น์? ํ ๋ฒ ActivityPub.Academy ์์ ์ํํด ๋ด
์๋ค. ์๊น์ ๋ง์ฐฌ๊ฐ์ง๋ก ActivityPub.Academy ๊ฒ์์ฐฝ์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ์ ์ฐํฉ์ฐ์ฃผ ํธ๋ค์ ์
๋ ฅํ์ฌ ๊ฒ์ํฉ๋๋ค:
์์ธํ ๋ณด๋ฉด ์กํฐ ์ด๋ฆ ์ค๋ฅธ์ชฝ์ ์๋ ํ๋ก ๋ฒํผ ์๋ฆฌ์ ์ธํ๋ก(unfollow) ๋ฒํผ์ด ์์ต๋๋ค. ์ด ๋ฒํผ์ ๋๋ฌ์ ํ๋ก๋ฅผ ํด์ ํ ๋ค, Activity Log ์ ๋ค์ด๊ฐ์ ์ด๋ค ์กํฐ๋นํฐ๊ฐ ์ ์ก๋๋ ํ์ธํด ๋ด
์๋ค:
์์ ๊ฐ์ด Undo(Follow) ์กํฐ๋นํฐ๊ฐ ์ ์ก๋์์ต๋๋ค. ์ฐํ๋จ์ show source ๋ฅผ ๋๋ฅด๋ฉด ์กํฐ๋นํฐ์ ์์ธํ ๋ด์ฉ์ ๋ณผ ์ ์์ต๋๋ค:
{
" @context " : " https://www.w3.org/ns/activitystreams " ,
" id " : " https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo " ,
" type " : " Undo " ,
" actor " : " https://activitypub.academy/users/dobussia_dovornath " ,
" object " : {
" id " : " https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694 " ,
" type " : " Follow " ,
" actor " : " https://activitypub.academy/users/dobussia_dovornath " ,
" object " : " https://temp-address.serveo.net/users/johndoe "
}
}
์ JSON ๊ฐ์ฒด๋ฅผ ๋ณด๋ฉด Undo(Follow) ์กํฐ๋นํฐ ์์ ์๊น ์์ ํจ์ผ๋ก ๋ค์ด์๋ Follow ์กํฐ๋นํฐ๊ฐ ํฌํจ๋์ด ์๋ ๊ฒ์ ๋ณผ ์ ์์ต๋๋ค. ํ์ง๋ง ์์ ํจ์์ Undo(Follow) ์กํฐ๋นํฐ๋ฅผ ์์ ํ์ ๋์ ๋์์ ์๋ฌด ๊ฒ๋ ์ ์ํ์ง ์์๊ธฐ์ ์๋ฌด ์ผ๋ ์ผ์ด๋์ง ์์์ต๋๋ค.
Undo(Follow) ์กํฐ๋นํฐ ์์
ํ๋ก ์ทจ์๋ฅผ ๊ตฌํํ๊ธฐ ์ํด src/federation.ts ํ์ผ์ ์ด์ด Fedify๊ฐ ์ ๊ณตํ๋ Undo ํด๋์ค๋ฅผ importํฉ๋๋ค:
import {
Accept ,
Endpoints ,
Follow ,
Person ,
Undo ,
createFederation ,
exportJwk ,
generateCryptoKeyPair ,
getActorHandle ,
importJwk ,
} from " @fedify/fedify " ;
๊ทธ๋ฆฌ๊ณ on(Follow, ...) ๋ค์ ์ฐ๋ฌ์ on(Undo, ...)๋ฅผ ์ถ๊ฐํฉ๋๋ค:
federation
. setInboxListeners ( " /users/{identifier}/inbox " , " /inbox " )
. on ( Follow , async ( ctx , follow ) => {
// ... ์๋ต๋จ ...
})
. on ( Undo , async ( ctx , undo ) => {
const object = await undo . getObject ();
if ( ! ( object instanceof Follow )) return ;
if ( undo . actorId == null || object . objectId == null ) return ;
const parsed = ctx . parseUri ( object . objectId );
if ( parsed == null || parsed . type !== " actor " ) return ;
db . prepare (
`
DELETE FROM follows
WHERE following_id = (
SELECT actors.id
FROM actors
JOIN users ON actors.user_id = users.id
WHERE users.username = ?
) AND follower_id = (SELECT id FROM actors WHERE uri = ?)
` ,
). run ( parsed . identifier , undo . actorId . href );
});
์ด๋ฒ์๋ ํ๋ก ์์ฒญ์ ์ฒ๋ฆฌํ ๋๋ณด๋ค ์ฝ๋๊ฐ ์งง์ต๋๋ค. Undo(Follow) ์กํฐ๋นํฐ ์์ ๋ ๊ฒ Follow ์กํฐ๋นํฐ์ธ์ง ํ์ธํ ๋ค, parseUri() ๋ฉ์๋๋ฅผ ์ด์ฉํด ์ทจ์ํ๋ ค๋ Follow ์กํฐ๋นํฐ์ ํ๋ก ๋์์ด ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ์ธ์ง ํ์ธํ๊ณ , follows ํ
์ด๋ธ์์ ํด๋นํ๋ ๋ ์ฝ๋๋ฅผ ์ญ์ ํฉ๋๋ค.
ํ
์คํธ
์๊น ActivityPub.Academy ์์ ์ด๋ฏธ ์ธํ๋ก ๋ฒํผ์ ๋๋ฌ๋ฒ๋ ค์ ํ ๋ฒ ๋ ์ธํ๋ก๋ฅผ ํ ์ ์์ต๋๋ค. ์ด์ฉ ์ ์์ด ๋ค์ ํ๋กํ ๋ค, ์ธํ๋กํ์ฌ ํ
์คํธ๋ฅผ ํด์ผ ํฉ๋๋ค. ํ์ง๋ง ๊ทธ์ ์์, follows ํ
์ด๋ธ์ ๋น์ ์ค ํ์๊ฐ ์์ต๋๋ค. ๊ทธ๋ ์ง ์์ผ๋ฉด ํ๋ก ์์ฒญ์ด ์์ ๋ ์ด๋ฏธ ๋ ์ฝ๋๊ฐ ์กด์ฌํ๋ฏ๋ก ์ค๋ฅ๊ฐ ๋ ๊ฒ์
๋๋ค.
sqlite3 ๋ช
๋ น์ด๋ฅผ ์ด์ฉํด follows ํ
์ด๋ธ์ ๋น์์๋ค:
echo " DELETE FROM follows; " | sqlite3 microblog.sqlite3
๊ทธ๋ฆฌ๊ณ ๋ค์ ํ๋ก ๋ฒํผ์ ๋๋ฅธ ๋ค, ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ํ์ธํฉ๋๋ค:
echo " SELECT * FROM follows; " | sqlite3 -table microblog.sqlite3
ํ๋ก ์์ฒญ์ด ์ ์ฒ๋ฆฌ๋์๋ค๋ฉด, ๋ค์๊ณผ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ๋์ต๋๋ค:
following_id
follower_id
created
1
2
2024-09-02 01:05:17
๊ทธ๋ฆฌ๊ณ ๋ค์ ์ธํ๋ก ๋ฒํผ์ ๋๋ฅธ ๋ค, ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ํ ๋ฒ ๋ ํ์ธํฉ๋๋ค:
echo " SELECT count(*) FROM follows; " | sqlite3 -table microblog.sqlite3
์ธํ๋ก ์์ฒญ์ด ์ ์ฒ๋ฆฌ๋์๋ค๋ฉด, ๋ ์ฝ๋๊ฐ ์ฌ๋ผ์ก์ผ๋ฏ๋ก ๋ค์๊ณผ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ๋์ต๋๋ค:
ํ๋ก์ ๋ชฉ๋ก
๋งค๋ฒ ํ๋ก์ ๋ชฉ๋ก์ sqlite3 ๋ช
๋ น์ผ๋ก ๋ณด๋ ๊ฑด ์ฑ๊ฐ์๋, ์น์ผ๋ก ํ๋ก์ ๋ชฉ๋ก์ ๋ณผ ์ ์๊ฒ ํฉ์๋ค.
์ฐ์ src/views.tsx ํ์ผ์ ์๋ก์ด ์ปดํฌ๋ํธ๋ฅผ ์ถ๊ฐํ๋ ๊ฒ์ผ๋ก ์์ํฉ๋๋ค. Actor ํ์
์ importํด์ฃผ์ธ์:
import type { Actor } from " ./schema.ts " ;
๊ทธ๋ฆฌ๊ณ <FollowerList> ์ปดํฌ๋ํธ์ <ActorLink> ์ปดํฌ๋ํธ๋ฅผ ์ ์ํฉ๋๋ค:
export interface FollowerListProps {
followers : Actor [];
}
export const FollowerList : FC < FollowerListProps > = ({ followers }) => (
<>
< h2 > Followers </ h2 >
< ul >
{ followers . map (( follower ) => (
< li key ={ follower . id }>
< ActorLink actor ={ follower } />
</ li >
))}
</ ul >
</>
);
export interface ActorLinkProps {
actor : Actor ;
}
export const ActorLink : FC < ActorLinkProps > = ({ actor }) => {
const href = actor . url ?? actor . uri ;
return actor . name == null ? (
< a href ={ href } class = " secondary " >
{ actor . handle }
</ a >
) : (
<>
< a href ={ href }>{ actor . name }</ a >{ " " }
< small >
(
< a href ={ href } class = " secondary " >
{ actor . handle }
</ a >
)
</ small >
</>
);
};
<ActorLink> ์ปดํฌ๋ํธ๋ ํ๋์ ์กํฐ๋ฅผ ํํํ๋ ๋ฐ์ ์ฐ์ด๊ณ , <FollowerList> ์ปดํฌ๋ํธ๋ <ActorList> ์ปดํฌ๋ํธ๋ฅผ ์ด์ฉํ์ฌ ํ๋ก์ ๋ชฉ๋ก์ ํํํ๋ ๋ฐ์ ์ฐ์
๋๋ค. ๋ณด๋ค์ํผ JSX์๋ ์กฐ๊ฑด๋ฌธ์ด๋ ๋ฐ๋ณต๋ฌธ์ด ์๊ธฐ ๋๋ฌธ์ ์ผํญ ์ฐ์ฐ์์ Array.map() ๋ฉ์๋๋ฅผ ์ด์ฉํ๊ณ ์์ต๋๋ค.
๊ทธ๋ผ ์ด์ ํ๋ก์ ๋ชฉ๋ก์ ํ์ํ๋ ์๋ํฌ์ธํธ๋ฅผ ๋ง๋ญ์๋ค. src/app.tsx ํ์ผ์ ์ด์ด <FollowerList> ์ปดํฌ๋ํธ๋ฅผ importํฉ๋๋ค:
import { FollowerList , Layout , Profile , SetupForm } from " ./views.tsx " ;
๊ทธ๋ฆฌ๊ณ GET /users/{username}/followers์ ๋ํ ์์ฒญ ํธ๋ค๋ฌ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
app . get ( " /users/:username/followers " , async ( c ) => {
const followers = db
. prepare < unknown [], Actor >(
`
SELECT followers.*
FROM follows
JOIN actors AS followers ON follows.follower_id = followers.id
JOIN actors AS following ON follows.following_id = following.id
JOIN users ON users.id = following.user_id
WHERE users.username = ?
ORDER BY follows.created DESC
` ,
)
. all ( c . req . param ( " username " ));
return c . html (
< Layout >
< FollowerList followers ={ followers } />
</ Layout >,
);
});
๊ทธ๋ผ, ์ ๋ณด์ด๋ ํ์ธํด ๋ณผ๊น์? ํ๋ก์๊ฐ ์์ด์ผ ํ ํ
๋, fedify tunnel์ ์ผ ์ฑ๋ก ๋ค๋ฅธ Mastodon ์๋ฒ๋ ActivityPub.Academy ์์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ฅผ ํ๋กํฉ์๋ค. ํ๋ก ์์ฒญ์ด ์๋ฝ๋ ๋ค ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/users/johndoe/followers ํ์ด์ง๋ฅผ ์ด๋ฉด, ์๋์ ๊ฐ์ด ๋ณด์ผ ๊ฒ์
๋๋ค:
ํ๋ก์ ๋ชฉ๋ก์ ๋ง๋ค์์ผ๋ ํ๋กํ ํ์ด์ง์์ ํ๋ก์ ์๋ ํ์ํ๋ฉด ์ข์ ๊ฒ ๊ฐ์ต๋๋ค. src/views.tsx ํ์ผ์ ๋ค์ ์ด๊ณ <Profile> ์ปดํฌ๋ํธ๋ฅผ ์๋์ ๊ฐ์ด ๊ณ ์นฉ๋๋ค:
export interface ProfileProps {
name : string ;
username : string ;
handle : string ;
followers : number ;
}
export const Profile : FC < ProfileProps > = ({
name ,
username ,
handle ,
followers ,
}) => (
<>
< hgroup >
< h1 >
< a href ={ ` /users/ ${ username } ` }>{ name }</ a >
</ h1 >
< p >
< span style = " user-select: all; " >{ handle }</ span > & middot ;{ " " }
< a href ={ ` /users/ ${ username } /followers ` }>
{ followers === 1 ? " 1 follower " : ` ${ followers } followers ` }
</ a >
</ p >
</ hgroup >
</>
);
ProfileProps์๋ ๋ ๊ฐ์ ํ๋กญ์ด ์ถ๊ฐ๋์์ต๋๋ค. followers๋ ๋ง ๊ทธ๋๋ก ํ๋ก์ ์๋ฅผ ๋ด๋ ํ๋กญ์
๋๋ค. username์ ํ๋ก์ ๋ชฉ๋ก์ผ๋ก ๋งํฌ๋ฅผ ๊ฑธ๊ธฐ ์ํด URL์ ๋ค์ด๊ฐ ์์ด๋๋ฅผ ๋ฐ์ต๋๋ค.
๊ทธ๋ฌ๋ฉด ๋ค์ src/app.tsx ํ์ผ๋ก ๋์๊ฐ, GET /users/{username} ์์ฒญ ํธ๋ค๋ฌ๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ์์ ํฉ๋๋ค:
app . get ( " /users/:username " , async ( c ) => {
// ... ์๋ต ...
if ( user == null ) return c . notFound ();
// biome-ignore lint/style/noNonNullAssertion: ์ธ์ ๋ ํ๋์ ๋ ์ฝ๋๋ฅผ ๋ฐํ
const { followers } = db
. prepare < unknown [], { followers : number }>(
`
SELECT count(*) AS followers
FROM follows
JOIN actors ON follows.following_id = actors.id
WHERE actors.user_id = ?
` ,
)
. get ( user . id ) ! ;
// ... ์๋ต ...
return c . html (
< Layout >
< Profile
name ={ user . name ?? user . username }
username ={ user . username }
handle ={ handle }
followers ={ followers }
/>
</ Layout >,
);
});
๋ฐ์ดํฐ๋ฒ ์ด์ค ์์ follows ํ
์ด๋ธ์ ๋ ์ฝ๋ ์๋ฅผ ์ธ๋ SQL์ด ์ถ๊ฐ๋์๊ตฐ์. ์, ๊ทธ๋ผ ์ด์ ๋ฐ๋ ํ๋กํ ํ์ด์ง๋ฅผ ํ์ธํด ๋ด
์๋ค. ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/users/johndoe ํ์ด์ง๋ฅผ ์ด๋ฉด ์๋์ ๊ฐ์ด ๋ณด์ผ ๊ฒ์
๋๋ค:
ํ๋ก์ ์ปฌ๋ ์
๊ทธ๋ฐ๋ฐ ํ ๊ฐ์ง ๋ฌธ์ ๊ฐ ์์ต๋๋ค. ActivityPub.Academy๊ฐ ์๋ ๋ค๋ฅธ Mastodon ์๋ฒ์์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ฅผ ์กฐํํด๋ด
์๋ค. (์กฐํํ๋ ๋ฒ์ ์ด์ ๋ค ์์์ฃ ? ๊ณต๊ฐ ์ธํฐ๋ท์ ๋
ธ์ถ๋ ์ํ์์, ์กํฐ ํธ๋ค์ Mastodon ๊ฒ์์ฐฝ์ ์น๋ฉด ๋ฉ๋๋ค.) Mastodon์์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ์ ํ๋กํ์ ๋ณด๋ฉด ์๋ง๋ ์ด์ํ ์ ์ ๋์น ์ฑ ์ ์์ ๊ฒ์
๋๋ค:
๋ฐ๋ก ํ๋ก์ ์๊ฐ 0์ผ๋ก ๋์จ๋ค๋ ๊ฒ์
๋๋ค. ์ด๋ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๊ฐ ํ๋ก์ ๋ชฉ๋ก์ ActivityPub์ ํตํด ๋
ธ์ถํ๊ณ ์์ง ์๊ธฐ ๋๋ฌธ์
๋๋ค. ActivityPub์์ ํ๋ก์ ๋ชฉ๋ก์ ๋
ธ์ถํ๋ ค๋ฉด ํ๋ก์ ์ปฌ๋ ์
์ ์ ์ํด์ผ ํฉ๋๋ค.
src/federation.ts ํ์ผ์ ์ด์ด Fedify๊ฐ ์ ๊ณตํ๋ Recipient ํ์
์ importํฉ๋๋ค:
import {
Accept ,
Endpoints ,
Follow ,
Person ,
Undo ,
createFederation ,
exportJwk ,
generateCryptoKeyPair ,
getActorHandle ,
importJwk ,
type Recipient ,
} from " @fedify/fedify " ;
๊ทธ๋ฆฌ๊ณ ์๋์ชฝ์ ํ๋ก์ ์ปฌ๋ ์
๋์คํจ์ฒ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
federation
. setFollowersDispatcher (
" /users/{identifier}/followers " ,
( ctx , identifier , cursor ) => {
const followers = db
. prepare < unknown [], Actor >(
`
SELECT followers.*
FROM follows
JOIN actors AS followers ON follows.follower_id = followers.id
JOIN actors AS following ON follows.following_id = following.id
JOIN users ON users.id = following.user_id
WHERE users.username = ?
ORDER BY follows.created DESC
` ,
)
. all ( identifier );
const items : Recipient [] = followers . map (( f ) => ({
id : new URL ( f . uri ),
inboxId : new URL ( f . inbox_url ),
endpoints :
f . shared_inbox_url == null
? null
: { sharedInbox : new URL ( f . shared_inbox_url ) },
}));
return { items };
},
)
. setCounter (( ctx , identifier ) => {
const result = db
. prepare < unknown [], { cnt : number }>(
`
SELECT count(*) AS cnt
FROM follows
JOIN actors ON actors.id = follows.following_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ?
` ,
)
. get ( identifier );
return result == null ? 0 : result . cnt ;
});
setFollowersDispatcher() ๋ฉ์๋์์๋ GET /users/{identifier}/followers ์์ฒญ์ด ์์ ๋ ์๋ตํ ํ๋ก์ ์ปฌ๋ ์
๊ฐ์ฒด๋ฅผ ๋ง๋ญ๋๋ค. SQL์ด ์กฐ๊ธ ๊ธธ๊ธด ํ์ง๋ง ์ ๋ฆฌํ์๋ฉด identifier ํ๋ผ๋ฏธํฐ๋ก ๋ค์ด์จ ์์ด๋๋ฅผ ํ๋กํ๋ ์กํฐ์ ๋ชฉ๋ก์ ๊ตฌํ๋ ๊ฒ์
๋๋ค. items์๋ Recipient ๊ฐ์ฒด๋ค์ ๋ด๋๋ฐ, Recipient ํ์
์ ๋ค์๊ณผ ๊ฐ์ด ์๊ฒผ์ต๋๋ค:
export interface Recipient {
readonly id : URL | null ;
readonly inboxId : URL | null ;
readonly endpoints ? : {
sharedInbox : URL | null ;
} | null ;
}
id ์์ฑ์๋ ์กํฐ์ ๊ณ ์ IRI๊ฐ ๋ค์ด๊ฐ๊ณ , inboxId์๋ ์กํฐ์ ๊ฐ์ธ ์์ ํจ URL์ด ๋ค์ด๊ฐ๋๋ค. endpoints.sharedInbox์๋ ์กํฐ์ ๊ณต์ ์์ ํจ URL์ด ๋ค์ด๊ฐ๋๋ค. ์ฐ๋ฆฌ๋ actors ํ
์ด๋ธ์ ๊ทธ ๋ชจ๋ ์ ๋ณด๋ฅผ ๋ค ๋ด๊ณ ์์ผ๋, ํด๋น ์ ๋ณด๋ค๋ก items ๋ฐฐ์ด์ ์ฑ์์ค ์ ์์ต๋๋ค.
setCounter() ๋ฉ์๋์์๋ ํ๋ก์ ์ปฌ๋ ์
์ ์ ์ฒด ์๋์ ๊ตฌํฉ๋๋ค. ์ฌ๊ธฐ์๋ SQL์ด ์กฐ๊ธ ๋ณต์กํ๊ธด ํ์ง๋ง ์์ฝํ๋ฉด identifier ํ๋ผ๋ฏธํฐ๋ก ๋ค์ด์จ ์์ด๋๋ฅผ ํ๋กํ๋ ์กํฐ์ ์๋ฅผ ๊ตฌํ๋ ๊ฒ์
๋๋ค.
๊ทธ๋ผ ํ๋ก์ ์ปฌ๋ ์
์ด ์ ๋์ํ๋์ง ํ์ธํ๊ธฐ ์ํด, fedify lookup ๋ช
๋ น์ ์ฌ์ฉํฉ์๋ค:
fedify lookup http://localhost:8000/users/johndoe/followers
์ ๋๋ก ๊ตฌํ๋์๋ค๋ฉด ์๋์ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ๋์ฌ ๊ฒ์
๋๋ค:
โ Looking up the object...
OrderedCollection {
totalItems: 1,
items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ]
}
๊ทธ๋ฐ๋ฐ, ์ด๋ ๊ฒ ํ๋ก์ ์ปฌ๋ ์
์ ๋ง๋ค์ด ๋๊ธฐ๋ง ํด์๋ ๋ค๋ฅธ ์๋ฒ์์ ํ๋ก์ ์ปฌ๋ ์
์ด ์ด๋ ์๋์ง ์ ์ ์์ต๋๋ค. ๊ทธ๋์ ์กํฐ ๋์คํจ์ฒ์์ ํ๋ก์ ์ปฌ๋ ์
์ ๋งํฌ๋ฅผ ๊ฑธ์ด ์ค์ผ ํฉ๋๋ค:
federation
. setActorDispatcher ( " /users/{identifier} " , async ( ctx , identifier ) => {
// ... ์๋ต ...
return new Person ({
// ... ์๋ต ...
followers : ctx . getFollowersUri ( identifier ),
});
})
์กํฐ๋ fedify lookup์ผ๋ก ์กฐํํ์ฌ ๋ด
์๋ค:
fedify lookup http://localhost:8000/users/johndoe
์๋์ ๊ฐ์ด ๊ฒฐ๊ณผ์ "followers" ์์ฑ์ด ํฌํจ๋์ด ์์ผ๋ฉด ๋ฉ๋๋ค:
โ Looking up the object...
Person {
... ์๋ต ...
inbox: URL "http://localhost:8000/users/johndoe/inbox",
followers: URL "http://localhost:8000/users/johndoe/followers",
endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}
๊ทธ๋ผ ์ด์ ๋ค์ Mastodon์์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ฅผ ์กฐํํด ๋ณผ๊น์? ํ์ง๋ง ๊ทธ ๊ฒฐ๊ณผ๋ ์ข ์ค๋ง์ค๋ฌ์ธ ์ ์์ต๋๋ค:
ํ๋ก์ ์๋ ์ฌ์ ํ 0์ผ๋ก ๋์ค๊ธฐ ๋๋ฌธ์ด์ฃ . ์ด๋ Mastodon์ด ๋ค๋ฅธ ์๋ฒ์ ์กํฐ ์ ๋ณด๋ฅผ ์บ์(cache)ํ๊ณ ์๊ธฐ ๋๋ฌธ์
๋๋ค. ์ด๋ฅผ ์
๋ฐ์ดํธํ๋ ๋ฐฉ๋ฒ์ด ์๊ธด ํ์ง๋ง F5 ํค๋ฅผ ๋๋ฅด๋ ๊ฒ์ฒ๋ผ ์ฝ์ง๋ ์์ต๋๋ค:
ํ ๊ฐ์ง ๋ฐฉ๋ฒ์ ์ผ์ฃผ์ผ์ ๊ธฐ๋ค๋ฆฌ๋ ๊ฒ์
๋๋ค. Mastodon์ ๋ค๋ฅธ ์๋ฒ์ ์กํฐ ์ ๋ณด๋ฅผ ๋ด๋ ์บ์๋ฅผ ๋ง์ง๋ง ์
๋ฐ์ดํธ ์ดํ 7์ผ์ด ์ง๋ ๋ ๋ ๋ฆฌ๊ธฐ ๋๋ฌธ์
๋๋ค.
๋ ๋ค๋ฅธ ๋ฐฉ๋ฒ์ Update ์กํฐ๋นํฐ๋ฅผ ๋ ๋ฆฌ๋ ๊ฒ์ธ๋ฐ, ๊ท์ฐฎ์ ์ฝ๋ฉ์ ํ์๋ก ํฉ๋๋ค.
์๋๋ฉด ์์ง ์บ์๊ฐ ๋์ง ์์ ๋ค๋ฅธ Mastodon ์๋ฒ์์ ์กฐํํด ๋ณด๋ ๊ฒ๋ ํ ๋ฐฉ๋ฒ์ด๊ฒ ์ฃ .
๋ง์ง๋ง ๋ฐฉ๋ฒ์ fedify tunnel์ ๊ป๋ค ์ผ์ ์๋ก์ด ์์ ๋๋ฉ์ธ์ ํ ๋น ๋ฐ๋ ๊ฒ์
๋๋ค.
์ฌ๋ฌ๋ถ์ด ๋ค๋ฅธ Mastodon ์๋ฒ์์ ์ ํํ ํ๋ก์ ์๊ฐ ํ์๋๋ ๊ฒ์ ์ง์ ํ์ธํ๊ณ ์ถ์ผ์๋ค๋ฉด ์ ๊ฐ ๋์ดํ ๋ฐฉ๋ฒ๋ค ์ค ํ๋๋ฅผ ์๋ํด ๋ณด์๊ธฐ ๋ฐ๋๋๋ค.
๊ฒ์๋ฌผ
์, ์ด์ ๋๋์ด ๊ฒ์๋ฌผ์ ๊ตฌํํ ๋๊ฐ ์์ต๋๋ค. ์ผ๋ฐ์ ์ธ ๋ธ๋ก๊ทธ์ ๋ฌ๋ฆฌ ์ฐ๋ฆฌ๊ฐ ๋ง๋ค ๋ง์ดํฌ๋ก๋ธ๋ก๊ทธ๋ ๋ค๋ฅธ ์๋ฒ์์ ์์ฑ๋ ๊ฒ์๋ฌผ๋ ์ ์ฅํ ์ ์์ด์ผ ํฉ๋๋ค. ์ด๋ฅผ ์ผ๋์ ๋๊ณ ์ค๊ณํด ๋ด
์๋ค.
ํ
์ด๋ธ ์์ฑ
๋ฐ๋ก posts ํ
์ด๋ธ๋ถํฐ ๋ง๋ญ์๋ค. src/schema.sql ํ์ผ์ ์ด์ด ์๋ SQL์ ์ถ๊ฐํฉ๋๋ค:
CREATE TABLE IF NOT EXISTS posts (
id INTEGER NOT NULL PRIMARY KEY ,
uri TEXT NOT NULL UNIQUE CHECK (uri <> '' ),
actor_id INTEGER NOT NULL REFERENCES actors (id),
content TEXT NOT NULL ,
url TEXT CHECK ( url LIKE ' https://% ' OR url LIKE ' http://% ' ),
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '' )
);
id ์นผ๋ผ์ ํ
์ด๋ธ์ ๊ธฐ๋ณธ ํค์
๋๋ค.
uri ์นผ๋ผ์ ๊ฒ์๋ฌผ์ ๊ณ ์ URI๋ฅผ ๋ด์ต๋๋ค. ์์ ๋งํ๋ค์ํผ ActivityPub ๊ฐ์ฒด๋ ๋ชจ๋ ๊ณ ์ ํ URI๋ฅผ ๊ฐ์ ธ์ผ ํ๊ธฐ ๋๋ฌธ์
๋๋ค.
actor_id ์นผ๋ผ์ ๊ฒ์๋ฌผ์ ์์ฑํ ์กํฐ๋ฅผ ๊ฐ๋ฆฌํต๋๋ค.
content ์นผ๋ผ์๋ ๊ฒ์๋ฌผ ๋ด์ฉ์ ๋ด์ต๋๋ค.
url ์นผ๋ผ์๋ ์น ๋ธ๋ผ์ฐ์ ์์ ๊ฒ์๋ฌผ์ ํ์ํ๋ URL์ ๋ด์ต๋๋ค. ActivityPub ๊ฐ์ฒด์ URI์ ์น ๋ธ๋ผ์ฐ์ ์ ํ์๋๋ ํ์ด์ง์ URL์ด ์ผ์นํ๋ ๊ฒฝ์ฐ๋ ์์ง๋ง, ๊ทธ๋ ์ง ์์ ๊ฒฝ์ฐ๋ ์๊ธฐ ๋๋ฌธ์ ๋ณ๋ ์นผ๋ผ์ด ํ์ํฉ๋๋ค. ํ์ง๋ง ๋น์ด ์์ ์๋ ์์ต๋๋ค.
created ์นผ๋ผ์๋ ๊ฒ์๋ฌผ ์์ฑ ์๊ฐ์ ๋ด์ต๋๋ค.
SQL์ ์คํํ์ฌ posts ํ
์ด๋ธ์ ์์ฑํฉ์๋ค:
sqlite3 microblog.sqlite3 < src/schema.sql
posts ํ
์ด๋ธ์ ์ ์ฅ๋ ๋ ์ฝ๋๋ฅผ JavaScript๋ก ํํํ๋ Post ํ์
๋ src/schema.ts ํ์ผ์ ์ ์ํฉ๋๋ค:
export interface Post {
id : number ;
uri : string ;
actor_id : number ;
content : string ;
url : string | null ;
created : string ;
}
์ฒซ ํ์ด์ง
๊ฒ์๋ฌผ์ ์์ฑํ๋ ค๋ฉด ์์์ด ์ด๋๊ฐ์ ์์ด์ผ๊ฒ ์ฃ ? ๊ทธ๋ฌ๊ณ ๋ณด๋, ์์ง๊น์ง ์ฒซ ํ์ด์ง๋ ์ ๋๋ก ๋ง๋ค์ง ์์์ต๋๋ค. ์ฒซ ํ์ด์ง์ ๊ฒ์๋ฌผ ์์ฑ ์์์ ์ถ๊ฐํ๊ฒ ์ต๋๋ค.
๋จผ์ src/views.tsx ํ์ผ์ ์ด์ด User ํ์
์ importํฉ๋๋ค:
import type { Actor , User } from " ./schema.ts " ;
๊ทธ๋ฆฌ๊ณ <Home> ์ปดํฌ๋ํธ๋ฅผ ์ ์ํฉ๋๋ค:
export interface HomeProps {
user : User & Actor ;
}
export const Home : FC < HomeProps > = ({ user }) => (
<>
< hgroup >
< h1 >{ user . name } 's microblog </ h1 >
< p >
< a href ={ ` /users/ ${ user . username } ` }>{ user . name } 's profile </ a >
</ p >
</ hgroup >
< form method = " post " action ={ ` /users/ ${ user . username } /posts ` }>
< fieldset >
< label >
< textarea name = " content " required ={ true } placeholder = " What's up? " />
</ label >
</ fieldset >
< input type = " submit " value = " Post " />
</ form >
</>
);
๊ทธ ๋ค์์๋ src/app.tsx ํ์ผ์ ์ด์ด ์์ ์ ์ํ <Home> ์ปดํฌ๋ํธ๋ฅผ importํฉ๋๋ค:
import { FollowerList , Home , Layout , Profile , SetupForm } from " ./views.tsx " ;
๊ทธ๋ฆฌ๊ณ ์ด๋ฏธ ์๋ GET / ์์ฒญ ํธ๋ค๋ฌ๋ฅผ:
app . get ( " / " , ( c ) => c . text ( " Hello, Fedify! " ));
์๋์ ๊ฐ์ด ๊ณ ์ณ์ค๋๋ค:
app . get ( " / " , ( c ) => {
const user = db
. prepare < unknown [], User & Actor >(
`
SELECT users.*, actors.*
FROM users
JOIN actors ON users.id = actors.user_id
LIMIT 1
` ,
)
. get ();
if ( user == null ) return c . redirect ( " /setup " );
return c . html (
< Layout >
< Home user ={ user } />
</ Layout >,
);
});
์ฌ๊ธฐ๊น์ง ํ๋ค๋ฉด, ํ ๋ฒ ์ฒซ ํ์ด์ง๊ฐ ์ ๋์ค๋ ํ์ธํฉ์๋ค. ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/ ํ์ด์ง๋ฅผ ์ด๋ฉด ์๋์ ๊ฐ์ด ๋ณด์ฌ์ผ ํฉ๋๋ค:
๋ ์ฝ๋ ์ฝ์
๊ฒ์๋ฌผ ์์ฑ ์์์ ๋ง๋ค์์ผ๋, ์ค์ ๋ก ๊ฒ์๋ฌผ ๋ด์ฉ์ posts ํ
์ด๋ธ์ ์ ์ฅํ๋ ์ฝ๋๊ฐ ํ์ํฉ๋๋ค.
๋จผ์ src/federation.ts ํ์ผ์ ์ด์ด Fedify๊ฐ ์ ๊ณตํ๋ Note ํด๋์ค๋ฅผ importํฉ๋๋ค:
import {
Accept ,
Endpoints ,
Follow ,
Note ,
Person ,
Undo ,
createFederation ,
exportJwk ,
generateCryptoKeyPair ,
getActorHandle ,
importJwk ,
type Recipient ,
} from " @fedify/fedify " ;
์๋ ์ฝ๋๋ฅผ ์ถ๊ฐํฉ๋๋ค:
federation . setObjectDispatcher (
Note ,
" /users/{identifier}/posts/{id} " ,
( ctx , values ) => {
return null ;
},
);
์ ์ฝ๋๋ ์์ง ๋ณ ์ญํ ์ ํ์ง ์์ง๋ง, ๊ฒ์๋ฌผ์ ํผ๋จธ๋งํฌ ํ์์ ์ ํ๋ ๋ฐ์ ํ์ํฉ๋๋ค. ์ค์ ๊ตฌํ์ ๋์ค์ ํ๋๋ก ํ๊ฒ ์ต๋๋ค.
ActivityPub์์๋ ๊ฒ์๋ฌผ์ ๋ด์ฉ์ HTML ํ์์ผ๋ก ์ฃผ๊ณ ๋ฐ์ต๋๋ค. ๋ฐ๋ผ์ ํ๋ฌธ ํ์์ผ๋ก ์
๋ ฅ ๋ฐ์ ๋ด์ฉ์ HTML ํ์์ผ๋ก ๋ณํํด์ผ ํฉ๋๋ค. ์ด ๋, <, >์ ๊ฐ์ ๋ฌธ์๋ค์ HTML์์ ํ์ํ ์ ์๋๋ก <, >์ ๊ฐ์ HTML ์ํฐํฐ๋ก ๋ณํํด์ฃผ๋ stringify-entities ํจํค์ง๊ฐ ํ์ํฉ๋๋ค:
npm add stringify-entities
๊ทธ๋ฆฌ๊ณ src/app.tsx ํ์ผ์ ์ด์ด ์ค์นํ ํจํค์ง๋ฅผ importํฉ๋๋ค.
import { stringifyEntities } from " stringify-entities " ;
Post ํ์
๊ณผ Fedify๊ฐ ์ ๊ณตํ๋ Note ํด๋์ค๋ importํฉ๋๋ค:
import type { Actor , Post , User } from " ./schema.ts " ;
import { Note } from " @fedify/fedify " ;
๊ทธ๋ฆฌ๊ณ POST /users/{username}/posts ์์ฒญ ํธ๋ค๋ฌ๋ฅผ ๊ตฌํํฉ๋๋ค:
app . post ( " /users/:username/posts " , async ( c ) => {
const username = c . req . param ( " username " );
const actor = db
. prepare < unknown [], Actor >(
`
SELECT actors.*
FROM actors
JOIN users ON users.id = actors.user_id
WHERE users.username = ?
` ,
)
. get ( username );
if ( actor == null ) return c . redirect ( " /setup " );
const form = await c . req . formData ();
const content = form . get ( " content " )?. toString ();
if ( content == null || content . trim () === "" ) {
return c . text ( " Content is required " , 400 );
}
const ctx = fedi . createContext ( c . req . raw , undefined );
const url : string | null = db . transaction (() => {
const post = db
. prepare < unknown [], Post >(
`
INSERT INTO posts (uri, actor_id, content)
VALUES ('https://localhost/', ?, ?)
RETURNING *
` ,
)
. get ( actor . id , stringifyEntities ( content , { escapeOnly : true }));
if ( post == null ) return null ;
const url = ctx . getObjectUri ( Note , {
identifier : username ,
id : post . id . toString (),
}). href ;
db . prepare ( " UPDATE posts SET uri = ?, url = ? WHERE id = ? " ). run (
url ,
url ,
post . id ,
);
return url ;
})();
if ( url == null ) return c . text ( " Failed to create post " , 500 );
return c . redirect ( url );
});
ํ๋ฒํ๊ฒ posts ํ
์ด๋ธ์ ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํ๋ ์ฝ๋์ด๊ธด ํ์ง๋ง ํ ๊ฐ์ง ํน์ดํ ๋ถ๋ถ์ด ์์ต๋๋ค. ๊ฒ์๋ฌผ์ ํํํ๋ ActivityPub ๊ฐ์ฒด์ URI๋ฅผ ๊ตฌํ๋ ค๋ฉด posts.id๊ฐ ๋จผ์ ๊ฒฐ์ ๋์ด ์์ด์ผ ํ๊ธฐ ๋๋ฌธ์, posts.uri ์นผ๋ผ์ https://localhost/๋ผ๋ ์์ URI๋ฅผ ๋จผ์ ์ง์ด ๋ฃ์ด ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํ ๋ค, ๊ฒฐ์ ๋ posts.id๋ฅผ ๊ธฐ๋ฐ์ผ๋ก getObjectUri() ๋ฉ์๋๋ฅผ ์ฌ์ฉํ์ฌ ์ค์ URI๋ฅผ ๊ตฌํด์ ๋ ์ฝ๋๋ฅผ ๊ฐฑ์ ํ๊ฒ ๋์ด ์์ต๋๋ค.
๊ทธ๋ผ ์ด์ ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/ ํ์ด์ง๋ฅผ ์ฐ ๋ค, ๊ฒ์๋ฌผ์ ์์ฑํด ๋ด
์๋ค:
Post ๋ฒํผ์ ๋๋ฌ ๊ฒ์๋ฌผ์ ์์ฑํ๋ฉด, ์ํ๊น๊ฒ๋ 404 Not Found ์ค๋ฅ๊ฐ ๋ฉ๋๋ค:
์๋ํ๋ฉด ๊ฒ์๋ฌผ ํผ๋จธ๋งํฌ๋ก ๋ฆฌ๋ค์ด๋ ํธํ๋๋ก ๊ตฌํํ๋๋ฐ, ์์ง ๊ฒ์๋ฌผ ํ์ด์ง๋ฅผ ๊ตฌํํ์ง ์์๊ธฐ ๋๋ฌธ์
๋๋ค. ํ์ง๋ง, ๊ทธ๋๋ posts ํ
์ด๋ธ์๋ ๋ ์ฝ๋๊ฐ ๋ง๋ค์ด์ก์ ๊ฒ์
๋๋ค. ํ ๋ฒ ํ์ธํด ๋ด
์๋ค:
echo " SELECT * FROM posts; " | sqlite3 -table microblog.sqlite3
๊ทธ๋ผ ๋ค์๊ณผ ๊ฐ์ ๊ฒฐ๊ณผ๋ ๋์ฌ ๊ฒ์
๋๋ค:
id
uri
actor_id
content
url
created
1
http://localhost:8000/users/johndoe/posts/1
1
It's my first post!
http://localhost:8000/users/johndoe/posts/1
2024-09-02 08:10:55
๊ฒ์๋ฌผ ํ์ด์ง
๊ฒ์๋ฌผ ์์ฑ ํ 404 Not Found ์ค๋ฅ๊ฐ ๋์ง ์๋๋ก, ๊ฒ์๋ฌผ ํ์ด์ง๋ฅผ ๊ตฌํํฉ์๋ค.
src/views.tsx ํ์ผ์ ์ด์ด Post ํ์
์ importํฉ๋๋ค:
import type { Actor , Post , User } from " ./schema.ts " ;
๊ทธ๋ฆฌ๊ณ <PostPage> ์ปดํฌ๋ํธ ๋ฐ <PostView> ์ปดํฌ๋ํธ๋ฅผ ์ ์ํฉ๋๋ค:
export interface PostPageProps extends ProfileProps , PostViewProps {}
export const PostPage : FC < PostPageProps > = ( props ) => (
<>
< Profile
name ={ props . name }
username ={ props . username }
handle ={ props . handle }
followers ={ props . followers }
/>
< PostView post ={ props . post } />
</>
);
export interface PostViewProps {
post : Post & Actor ;
}
export const PostView : FC < PostViewProps > = ({ post }) => (
< article >
< header >
< ActorLink actor ={ post } />
</ header >
{ /* biome-ignore lint/security/noDangerouslySetInnerHtml: */ }
< div dangerouslySetInnerHTML ={{ __html : post . content }} />
< footer >
< a href ={ post . url ?? post . uri }>
< time datetime ={ new Date ( post . created ). toISOString ()}>
{ post . created }
</ time >
</ a >
</ footer >
</ article >
);
์ด์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ๊ฒ์๋ฌผ ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ <PostPage> ์ปดํฌ๋ํธ๋ก ๋ ๋๋งํฉ์๋ค. src/app.tsx ํ์ผ์ ์ด๊ณ ์์ ์ ์ํ <PostPage> ์ปดํฌ๋ํธ๋ฅผ importํฉ๋๋ค:
import {
FollowerList ,
Home ,
Layout ,
PostPage ,
Profile ,
SetupForm ,
} from " ./views.tsx " ;
๊ทธ๋ฆฌ๊ณ GET /users/{username}/posts/{id} ์์ฒญ ํธ๋ค๋ฌ๋ฅผ ๊ตฌํํฉ๋๋ค:
app . get ( " /users/:username/posts/:id " , ( c ) => {
const post = db
. prepare < unknown [], Post & Actor & User >(
`
SELECT users.*, actors.*, posts.*
FROM posts
JOIN actors ON actors.id = posts.actor_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ? AND posts.id = ?
` ,
)
. get ( c . req . param ( " username " ), c . req . param ( " id " ));
if ( post == null ) return c . notFound ();
// biome-ignore lint/style/noNonNullAssertion: ์ธ์ ๋ ํ๋์ ๋ ์ฝ๋๋ฅผ ๋ฐํ
const { followers } = db
. prepare < unknown [], { followers : number }>(
`
SELECT count(*) AS followers
FROM follows
WHERE follows.following_id = ?
` ,
)
. get ( post . actor_id ) ! ;
return c . html (
< Layout >
< PostPage
name ={ post . name ?? post . username }
username ={ post . username }
handle ={ post . handle }
followers ={ followers }
post ={ post }
/>
</ Layout >,
);
});
๊ทธ๋ผ ์๊น 404 Not Found ์ค๋ฅ๊ฐ ๋ฌ๋ http://localhost:8000/users/johndoe/posts/1 ํ์ด์ง๋ฅผ ์น ๋ธ๋ผ์ฐ์ ์์ ์ด์ด ๋ด
์๋ค:
Note ๊ฐ์ฒด ๋์คํจ์ฒ
๊ทธ๋ผ ์ด์ ๊ฒ์๋ฌผ์ ๋ค๋ฅธ Mastodon ์๋ฒ์์ ์กฐํํ ์ ์๋ ํ์ธํด ๋ณผ๊น์? ๋จผ์ fedify tunnel์ ์ด์ฉํ์ฌ ๋ก์ปฌ ์๋ฒ๋ฅผ ๊ณต๊ฐ ์ธํฐ๋ท์ ๋
ธ์ถํฉ๋๋ค.
๊ทธ ์ํ์์, Mastodon ๊ฒ์์ฐฝ์ ๊ธ์ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1 (์ฌ๋ฌ๋ถ์ ์์ ๋๋ฉ์ธ ์ด๋ฆ์ผ๋ก ์นํํ์ธ์)์ ์ณ๋ด
์๋ค:
์ํ๊น๊ฒ๋ ๊ฒ์ ๊ฒฐ๊ณผ๋ ๋น์ด ์์ต๋๋ค. ๊ฒ์๋ฌผ์ ActivityPub ๊ฐ์ฒด ํ์์ผ๋ก ๋
ธ์ถํ์ง ์์๊ธฐ ๋๋ฌธ์
๋๋ค. ๊ทธ๋ผ ๊ฒ์๋ฌผ์ ActivityPub ๊ฐ์ฒด๋ก ๋
ธ์ถํด ๋ด
์๋ค.
๊ตฌํ์ ์์ ํ์ํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ค์นํด์ผ ํฉ๋๋ค. Fedify์์ ์๊ฐ์ ํํํ๋ ๋ฐ์ ์ฐ๋ Temporal API ๊ฐ ์์ง Node.js์ ๋ด์ฅ๋์ด ์์ง ์๊ธฐ ๋๋ฌธ์ ์ด๋ฅผ ํด๋ฆฌํ(polyfill)ํด์ฃผ๋ @js-temporal/polyfill ํจํค์ง๊ฐ ํ์ํฉ๋๋ค:
npm add @js-temporal/polyfill
src/federation.ts ํ์ผ์ ์ด์ด ์ค์นํ ํจํค์ง๋ฅผ importํฉ๋๋ค:
import { Temporal } from " @js-temporal/polyfill " ;
Post ํ์
๊ณผ Fedify๊ฐ ์ ๊ณตํ๋ PUBLIC_COLLECTION ์์๋ importํฉ๋๋ค.
import {
Accept ,
Endpoints ,
Follow ,
Note ,
PUBLIC_COLLECTION ,
Person ,
Undo ,
createFederation ,
exportJwk ,
generateCryptoKeyPair ,
getActorHandle ,
importJwk ,
type Recipient ,
} from " @fedify/fedify " ;
import type {
Actor ,
Key ,
Post ,
User ,
} from " ./schema.ts " ;
๋ง์ดํฌ๋ก๋ธ๋ก๊ทธ์ ๊ฒ์๋ฌผ์ฒ๋ผ ์งง์ ๊ธ์ ActivityPub์์ ๋ณดํต Note๋ก ํํ๋ฉ๋๋ค. Note ํด๋์ค์ ๋ํ ๊ฐ์ฒด ๋์คํจ์ฒ๋ ์ด๋ฏธ ๋น ๊ตฌํ์ด๋๋ง ๋ง๋ค์ด ๋์์์ฃ :
federation . setObjectDispatcher (
Note ,
" /users/{identifier}/posts/{id} " ,
( ctx , values ) => {
return null ;
},
);
์ด๋ฅผ ์๋์ ๊ฐ์ด ๊ณ ์นฉ๋๋ค:
federation . setObjectDispatcher (
Note ,
" /users/{identifier}/posts/{id} " ,
( ctx , values ) => {
const post = db
. prepare < unknown [], Post >(
`
SELECT posts.*
FROM posts
JOIN actors ON actors.id = posts.actor_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ? AND posts.id = ?
` ,
)
. get ( values . identifier , values . id );
if ( post == null ) return null ;
return new Note ({
id : ctx . getObjectUri ( Note , values ),
attribution : ctx . getActorUri ( values . identifier ),
to : PUBLIC_COLLECTION ,
cc : ctx . getFollowersUri ( values . identifier ),
content : post . content ,
mediaType : " text/html " ,
published : Temporal . Instant . from ( ` ${ post . created . replace ( " " , " T " ) } Z ` ),
url : ctx . getObjectUri ( Note , values ),
});
},
);
Note ๊ฐ์ฒด๋ฅผ ์์ฑํ ๋ ์ฑ์์ง๋ ์์ฑ ๊ฐ๋ค์ ๋ค์๊ณผ ๊ฐ์ ์ญํ ์ ํฉ๋๋ค:
attribution ์์ฑ์ ctx.getActorUri(values.identifier)์ ๋ฃ๋ ๊ฒ์ ์ด ๊ฒ์๋ฌผ์ ์์ฑ์๊ฐ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ผ๋ ๊ฒ์ ๋ํ๋
๋๋ค.
to ์์ฑ์ PUBLIC_COLLECTION์ ๋ฃ๋ ๊ฒ์ ์ด ๊ฒ์๋ฌผ์ด ์ ์ฒด ๊ณต๊ฐ ๊ฒ์๋ฌผ์ด๋ผ๋ ๊ฒ์ ๋ํ๋
๋๋ค.
cc ์์ฑ์ ctx.getFollowersUri(values.identifier)์ ๋ฃ๋ ๊ฒ์ ์ด ๊ฒ์๋ฌผ์ด ํ๋ก์๋ค์๊ฒ ์ ๋ฌ๋๋ค๋ ๊ฒ์ ๋ํ๋ด์ง๋ง, ์ด ์์ฒด๋ก๋ ํฐ ์๋ฏธ๋ ์์ต๋๋ค.
๊ทธ๋ฌ๋ฉด ๋ค์ ํ ๋ฒ Mastodon ๊ฒ์์ฐฝ์ ๊ฒ์๋ฌผ์ ํผ๋จธ๋งํฌ์ธ https://temp-address.serveo.net/users/johndoe/posts/1 (์ฌ๋ฌ๋ถ์ ์์ ๋๋ฉ์ธ ์ด๋ฆ์ผ๋ก ์นํํ์ธ์)์ ์ณ๋ด
์๋ค:
์ด๋ฒ์๋ ๊ฒ์ ๊ฒฐ๊ณผ์ ์ ๋๋ก ์ฐ๋ฆฌ๊ฐ ์์ฑํ ๊ฒ์๋ฌผ์ด ๋์ค๋ค์!
Create(Note) ์กํฐ๋นํฐ ๋ฐ์
ํ์ง๋ง Mastodon์์ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ฅผ ํ๋ก ํด๋, ์๋ก ์์ฑํ ๊ฒ์๋ฌผ์ด Mastodon ํ์๋ผ์ธ์ ์ฌ๋ผ์ค์ง๋ ์์ต๋๋ค. ์๋ํ๋ฉด Mastodon์ด ์ ๊ฒ์๋ฌผ์ ์์์ ๋ฐ์๊ฐ๋ ๊ฒ ์๋๋ผ, ์ ๊ฒ์๋ฌผ์ ์์ฑํ ์ชฝ์์ Create(Note) ์กํฐ๋นํฐ๋ฅผ ์ ์กํ์ฌ ์ ๊ฒ์๋ฌผ์ด ๋ง๋ค์ด์ก๋ค๋ ๊ฒ์ ์๋ ค์ค์ผ ํ๊ธฐ ๋๋ฌธ์
๋๋ค.
๊ฒ์๋ฌผ ์์ฑ์์ Create(Note) ์กํฐ๋นํฐ๋ฅผ ์ ์กํ๋๋ก ์ฝ๋๋ฅผ ๊ณ ์ณ๋ด
์๋ค. src/app.tsx ํ์ผ์ ์ด์ด Fedify๊ฐ ์ ๊ณตํ๋ Create ํด๋์ค๋ฅผ importํฉ๋๋ค:
import { Create , Note } from " @fedify/fedify " ;
๊ทธ๋ฆฌ๊ณ POST /users/{username}/posts ์์ฒญ ํธ๋ค๋ฌ๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ์์ ํฉ๋๋ค:
app . post ( " /users/:username/posts " , async ( c ) => {
// ... ์๋ต ...
const ctx = fedi . createContext ( c . req . raw , undefined );
const post : Post | null = db . transaction (() => {
const post = db
. prepare < unknown [], Post >(
`
INSERT INTO posts (uri, actor_id, content)
VALUES ('https://localhost/', ?, ?)
RETURNING *
` ,
)
. get ( actor . id , stringifyEntities ( content , { escapeOnly : true }));
if ( post == null ) return null ;
const url = ctx . getObjectUri ( Note , {
identifier : username ,
id : post . id . toString (),
}). href ;
db . prepare ( " UPDATE posts SET uri = ?, url = ? WHERE id = ? " ). run (
url ,
url ,
post . id ,
);
return post ;
})();
if ( post == null ) return c . text ( " Failed to create post " , 500 );
const noteArgs = { identifier : username , id : post . id . toString () };
const note = await ctx . getObject ( Note , noteArgs );
await ctx . sendActivity (
{ identifier : username },
" followers " ,
new Create ({
id : new URL ( " #activity " , note ?. id ?? undefined ),
object : note ,
actors : note ?. attributionIds ,
tos : note ?. toIds ,
ccs : note ?. ccIds ,
}),
);
return c . redirect ( ctx . getObjectUri ( Note , noteArgs ). href );
});
getObject() ๋ฉ์๋๋ ๊ฐ์ฒด ๋์คํจ์ฒ๊ฐ ๋ง๋ค์ด ์ฃผ๋ ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐํํฉ๋๋ค. ์ฌ๊ธฐ์๋ Note ๊ฐ์ฒด๋ฅผ ๋ฐํํ๊ฒ ์ฃ . ๊ทธ Note ๊ฐ์ฒด๋ฅผ Create ๊ฐ์ฒด๋ฅผ ์์ฑํ ๋ object ์์ฑ์ ๋ฃ์ต๋๋ค. ์กํฐ๋นํฐ์ ์์ ์๋ฅผ ๋ํ๋ด๋ tos (to์ ๋ณต์ํ) ๋ฐ ccs (cc์ ๋ณต์ํ) ์์ฑ์ Note ๊ฐ์ฒด์ ๋์ผํ๊ฒ ์ค์ ํฉ๋๋ค. ์กํฐ๋นํฐ์ id๋ ์์์ ๊ณ ์ ํ URI๋ฅผ ์ง์ด๋ด์ ์ค์ ํฉ๋๋ค.
ํ
์กํฐ๋นํฐ ๊ฐ์ฒด์ id ์์ฑ์๋ ๋ฐ๋์ ์ ๊ทผ ๊ฐ๋ฅํ URI๊ฐ ๋ค์ด๊ฐ ํ์๋ ์์ต๋๋ค. ๊ทธ์ ๊ณ ์ ํ๊ธฐ๋ง ํ๋ฉด ๋ฉ๋๋ค.
sendActivity() ๋ฉ์๋์ ๋ ๋ฒ์งธ ํ๋ผ๋ฏธํฐ์๋ ์์ ์๊ฐ ๋ค์ด๊ฐ๋๋ฐ, ์ฌ๊ธฐ์๋ "followers"๋ผ๋ ํน์ํ ์ต์
์ ์ง์ ํ์ต๋๋ค. ์ด ์ต์
์ ์ง์ ํ๋ฉด ์์ ๊ตฌํํ๋ ํ๋ก์ ์ปฌ๋ ์
๋์คํจ์ฒ๋ฅผ ์ด์ฉํ์ฌ ๋ชจ๋ ํ๋ก์๋ค์๊ฒ ์กํฐ๋นํฐ๋ฅผ ์ ์กํ๊ฒ ๋ฉ๋๋ค.
์, ๊ตฌํ์ ๋๋์ผ๋ Create(Note) ์กํฐ๋นํฐ๊ฐ ์ ์ ์ก๋๋ ํ์ธํด ๋ณผ๊น์?
fedify tunnel ๋ช
๋ น์ผ๋ก ๋ก์ปฌ ์๋ฒ๋ฅผ ๊ณต๊ฐ ์ธํฐ๋ท์ ๋
ธ์ถ์ํจ ์ฑ, ActivityPub.Academy ๋ก ๋ค์ด๊ฐ @johndoe@temp-address.serveo.net (๋๋ฉ์ธ ์ด๋ฆ์ ์ฌ๋ฌ๋ถ์๊ฒ ํ ๋น๋ ์์ ๋๋ฉ์ธ ์ด๋ฆ์ผ๋ก ์นํํ์ธ์)๋ฅผ ํ๋กํฉ๋๋ค. ํ๋ก์ ๋ชฉ๋ก์์ ํ๋ก ์์ฒญ์ด ํ์คํ ์๋ฝ๋ ๊ฒ์ ํ์ธํ ๋ค, ์น ๋ธ๋ผ์ฐ์ ์์ https://temp-address.serveo.net/ (๋ง์ฐฌ๊ฐ์ง๋ก, ๋๋ฉ์ธ ์ด๋ฆ์ ์นํํ์ธ์) ํ์ด์ง๋ฅผ ๋ค์ด๊ฐ ์ ๊ฒ์๋ฌผ์ ์์ฑํฉ๋๋ค.
๊ฒฝ๊ณ
์กํฐ๋นํฐ ์ ์ก์ ํ
์คํธํ ๋๋ ๋ฐ๋์ localhost ๊ฐ ์๋ ๊ณต๊ฐ ์ธํฐ๋ท์์ ์ ๊ทผ ๊ฐ๋ฅํ ๋๋ฉ์ธ ์ด๋ฆ์ผ๋ก ์ ์ํด์ผ ํฉ๋๋ค. ActivityPub ๊ฐ์ฒด์ ID๋ฅผ ๊ฒฐ์ ํ ๋ ์์ฒญ์ด ๋ค์ด์จ ๋๋ฉ์ธ ์ด๋ฆ์ ๊ธฐ์ค์ผ๋ก URI๋ฅผ ๊ตฌํ๊ธฐ ๋๋ฌธ์
๋๋ค.
Create(Note) ์กํฐ๋นํฐ๊ฐ ์ ๊ฐ๋์ง ํ์ธํ๊ธฐ ์ํด, ActivityPub.Academy์ Activity Log ๋ฅผ ์ดํด๋ด
์๋ค:
์ ๋ค์ด์๋ค์. ๊ทธ๋ผ ActivityPub.Academy์์ ํ์๋ผ์ธ์ ์ดํด๋ด
์๋ค:
ํด๋์ต๋๋ค!
ํ๋กํ ํ์ด์ง ๋ด ๊ฒ์๋ฌผ ๋ชฉ๋ก
ํ์ฌ ํ๋กํ ํ์ด์ง์๋ ์ด๋ฆ๊ณผ ์ฐํฉ์ฐ์ฃผ ํธ๋ค, ํ๋ก์ ์๋ง ๋์ฌ ๋ฟ ์ ์ ๊ฒ์๋ฌผ์ ๋ณด์ด์ง ์์ต๋๋ค. ํ๋กํ ํ์ด์ง์์ ์์ฑํ ๊ฒ์๋ฌผ์ ๋ณด์ฌ์ค์๋ค.
src/views.tsx ํ์ผ์ ์ด๊ณ <PostList> ์ปดํฌ๋ํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
export interface PostListProps {
posts : ( Post & Actor )[];
}
export const PostList : FC < PostListProps > = ({ posts }) => (
<>
{ posts . map (( post ) => (
< div key ={ post . id }>
< PostView post ={ post } />
</ div >
))}
</>
);
๊ทธ๋ฆฌ๊ณ src/app.tsx ํ์ผ์ ์ด๊ณ , ๋ฐฉ๊ธ ์ ์ํ <PostList> ์ปดํฌ๋ํธ๋ฅผ importํฉ๋๋ค:
import {
FollowerList ,
Home ,
Layout ,
PostList ,
PostPage ,
Profile ,
SetupForm ,
} from " ./views.tsx " ;
์ด๋ฏธ ์๋ GET /users/{username} ์์ฒญ ํธ๋ค๋ฌ๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ๋ณ๊ฒฝํฉ๋๋ค:
app . get ( " /users/:username " , async ( c ) => {
// ... ์๋ต ...
const posts = db
. prepare < unknown [], Post & Actor >(
`
SELECT actors.*, posts.*
FROM posts
JOIN actors ON posts.actor_id = actors.id
WHERE actors.user_id = ?
ORDER BY posts.created DESC
` ,
)
. all ( user . user_id );
// ... ์๋ต ...
return c . html (
< Layout >
// ... ์๋ต ...
< PostList posts ={ posts } />
</ Layout >,
);
});
๊ทธ๋ผ ์ด์ ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/users/johndoe ํ์ด์ง๋ฅผ ์ด์ด๋ด
์๋ค:
์์ฑํ ๊ฒ์๋ฌผ๋ค์ด ์ ๋์ค๋ ๊ฒ์ ๋ณผ ์ ์์ต๋๋ค.
ํ๋ก
ํ์ฌ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๋ ๋ค๋ฅธ ์๋ฒ์ ์กํฐ๋ก๋ถํฐ ํ๋ก ์์ฒญ์ ๋ฐ์ ์๋ ์์ง๋ง, ๋ค๋ฅธ ์๋ฒ์ ์กํฐ์๊ฒ ํ๋ก ์์ฒญ์ ๋ณด๋ผ ์๋ ์์ต๋๋ค. ํ๋ก๋ฅผ ํ ์ ์์ผ๋ ๋ค๋ฅธ ์กํฐ๊ฐ ์์ฑํ ๊ฒ์๋ฌผ๋ ๋ณผ ์ ์์ต๋๋ค. ์, ๊ทธ๋ผ ๋ค๋ฅธ ์๋ฒ์ ์กํฐ์ ํ๋ก ์์ฒญ์ ๋ณด๋ด๋ ๊ธฐ๋ฅ์ ์ถ๊ฐํฉ์๋ค.
UI ๋จผ์ ๋ง๋ญ์๋ค. src/views.tsx ํ์ผ์ ์ด๊ณ , ์ด๋ฏธ ์๋ <Home> ์ปดํฌ๋ํธ๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ์์ ํฉ๋๋ค:
export const Home : FC < HomeProps > = ({ user }) => (
<>
< hgroup >
{ /* ... ์๋ต ... */ }
</ hgroup >
< form method = " post " action ={ ` /users/ ${ user . username } /following ` }>
{ /* biome-ignore lint/a11y/noRedundantRoles: PicoCSS๊ฐ role=group์ ์๊ตฌํจ */ }
< fieldset role = " group " >
< input
type = " text "
name = " actor "
required ={ true }
placeholder = " Enter an actor handle (e.g., @johndoe@mastodon.com) or URI (e.g., https://mastodon.com/@johndoe) "
/>
< input type = " submit " value = " Follow " />
</ fieldset >
</ form >
< form method = " post " action ={ ` /users/ ${ user . username } /posts ` }>
{ /* ... ์๋ต ... */ }
</ form >
</>
);
์ฒซ ํ์ด์ง๊ฐ ์ ์์ ๋์๋์ง ํ์ธํ๊ธฐ ์ํด ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/ ํ์ด์ง๋ฅผ ์ด์ด ๋ด
์๋ค:
Follow ์กํฐ๋นํฐ ์ ์ก
ํ๋ก ์์ฒญ UI๊ฐ ์๊ฒผ์ผ๋ ์ค์ ๋ก Follow ์กํฐ๋นํฐ๋ฅผ ์ ์กํ๋ ์ฝ๋๋ฅผ ์งค ์ฐจ๋ก์
๋๋ค.
src/app.tsx ํ์ผ์ ์ด๊ณ Fedify๊ฐ ์ ๊ณตํ๋ Follow ํด๋์ค์ isActor() ํจ์๋ฅผ importํฉ๋๋ค:
import {
Create ,
Follow ,
isActor ,
Note ,
} from " @fedify/fedify " ;
๊ทธ๋ฆฌ๊ณ POST /users/{username}/following ์์ฒญ ํธ๋ค๋ฌ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
app . post ( " /users/:username/following " , async ( c ) => {
const username = c . req . param ( " username " );
const form = await c . req . formData ();
const handle = form . get ( " actor " );
if ( typeof handle !== " string " ) {
return c . text ( " Invalid actor handle or URL " , 400 );
}
const ctx = fedi . createContext ( c . req . raw , undefined );
const actor = await lookupObject ( handle . trim ());
if ( ! isActor ( actor )) {
return c . text ( " Invalid actor handle or URL " , 400 );
}
await ctx . sendActivity (
{ identifier : username },
actor ,
new Follow ({
actor : ctx . getActorUri ( username ),
object : actor . id ,
to : actor . id ,
}),
);
return c . text ( " Successfully sent a follow request " );
});
lookupObject() ํจ์๋ ์กํฐ๋ฅผ ๋น๋กฏํ ActivityPub ๊ฐ์ฒด๋ฅผ ์กฐํํฉ๋๋ค. ์
๋ ฅ์ผ๋ก ActivityPub ๊ฐ์ฒด์ ๊ณ ์ URI๋ ์ฐํฉ์ฐ์ฃผ ํธ๋ค์ ๋ฐ๊ณ , ์กฐํํ ActivityPub ๊ฐ์ฒด๋ฅผ ๋ฐํํฉ๋๋ค.
isActor() ํจ์๋ ์ฃผ์ด์ง ActivityPub ๊ฐ์ฒด๊ฐ ์กํฐ์ธ์ง ํ์ธํฉ๋๋ค.
์ด ์ฝ๋์์๋ sendActivity() ๋ฉ์๋๋ฅผ ์ด์ฉํด ์กฐํํ ์กํฐ์๊ฒ Follow ์กํฐ๋นํฐ๋ฅผ ๋ณด๋ด๊ณ ์์ต๋๋ค. ํ์ง๋ง, ์์ง follows ํ
์ด๋ธ์ ์๋ฌด๋ฐ ๋ ์ฝ๋๋ ์ถ๊ฐํ์ง ์๊ณ ์์ต๋๋ค. ์๋ํ๋ฉด ์๋๋ก๋ถํฐ Accept(Follow) ์กํฐ๋นํฐ๋ฅผ ๋ฐ๊ณ ๋์ ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํด์ผ ํ๊ธฐ ๋๋ฌธ์
๋๋ค.
ํ
์คํธ
๊ตฌํํ ํ๋ก ์์ฒญ ๊ธฐ๋ฅ์ด ์ ์๋ํ๋์ง ํ์ธํด์ผ ํฉ๋๋ค. ์ด๋ฒ์๋ ์กํฐ๋นํฐ๋ฅผ ์ ์กํด์ผ ํ๋ฏ๋ก, fedify tunnel ๋ช
๋ น์ ์ด์ฉํด ๋ก์ปฌ ์๋ฒ๋ฅผ ๊ณต๊ฐ ์ธํฐ๋ท์ ๋
ธ์ถํ ๋ค, ์น ๋ธ๋ผ์ฐ์ ์์ https://temp-address.serveo.net/ (๋๋ฉ์ธ ์ด๋ฆ์ ์นํํ์ธ์) ํ์ด์ง๋ฅผ ๋ค์ด๊ฐ๋๋ค:
ํ๋ก ์์ฒญ ์
๋ ฅ์ฐฝ์ ํ๋กํ ์กํฐ์ ์ฐํฉ์ฐ์ฃผ ํธ๋ค์ ์
๋ ฅํด์ผ ํฉ๋๋ค. ์ฌ๊ธฐ์๋ ์ฌ์ด ๋๋ฒ๊น
์ ์ํด ActivityPub.Academy ์ ์กํฐ๋ฅผ ์
๋ ฅํ๋๋ก ํฉ์๋ค. ์ฐธ๊ณ ๋ก, ActivityPub.Academy์์ ๋ก๊ทธ์ธ ๋ ์์ ๊ณ์ ์ ํธ๋ค์ ์์ ๊ณ์ ์ ์ด๋ฆ์ ํด๋ฆญํ์ฌ ํ๋กํ ํ์ด์ง์ ๋ค์ด๊ฐ๋ฉด ์ด๋ฆ ๋ฐ๋ก ์๋์์ ๋ณผ ์ ์์ต๋๋ค:
๋ค์๊ณผ ๊ฐ์ด ActivityPub.Academy์ ์กํฐ ํธ๋ค์ ์
๋ ฅํ ๋ค, Follow ๋ฒํผ์ ๋๋ฌ ํ๋ก ์์ฒญ์ ๋ณด๋
๋๋ค:
๊ทธ๋ฆฌ๊ณ ActivityPub.Academy์ Activity Log ๋ฅผ ํ์ธํฉ๋๋ค:
Activity Log ์๋ ์ฐ๋ฆฌ๊ฐ ์ ์กํ Follow ์กํฐ๋นํฐ์, ActivityPub.Academy๋ก๋ถํฐ ์ ์ก๋ ๋ต์ฅ์ธ Accept(Follow) ์กํฐ๋นํฐ๊ฐ ํ์๋ฉ๋๋ค.
ActivityPub.Academy์ ์๋ฆผ ํ์ด์ง๋ก ๊ฐ๋ฉด ์ค์ ๋ก ํ๋ก ์์ฒญ์ด ๋์ฐฉํ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค:
Accept(Follow) ์กํฐ๋นํฐ ์์
ํ์ง๋ง ์์ง ์์ ๋ Accept(Follow) ์กํฐ๋นํฐ์ ๋ํด ์๋ฌด๋ฐ ํ๋๋ ์ทจํ๊ณ ์์ง ์๊ธฐ ๋๋ฌธ์, ์ด ๋ถ๋ถ์ ๊ตฌํํด์ผ ํฉ๋๋ค.
src/federation.ts ํ์ผ์ ์ด์ด Fedify์์ ์ ๊ณตํ๋ isActor() ํจ์ ๋ฐ Actor ํ์
์ importํฉ๋๋ค:
import {
Accept ,
Endpoints ,
Follow ,
Note ,
PUBLIC_COLLECTION ,
Person ,
Undo ,
createFederation ,
exportJwk ,
generateCryptoKeyPair ,
getActorHandle ,
importJwk ,
isActor ,
type Actor as APActor ,
type Recipient ,
} from " @fedify/fedify " ;
์ด ์์ค ํ์ผ ์์์ Actor ํ์
์ ์ด๋ฆ์ด ๊ฒน์น๋ฏ๋ก APActor๋ผ๋ ๋ณ๋ช
์ ์ง์ด์คฌ์ต๋๋ค.
๊ตฌํ์ ์์, ์ฒ์ ๋ง์ฃผํ ์กํฐ ์ ๋ณด๋ฅผ actors ํ
์ด๋ธ์ ๋ฃ๋ ์ฝ๋๋ฅผ ๋ฆฌํฉํฐ๋งํ์ฌ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ๊ฒ ๋ฐ๊ฟ๋ด
์๋ค. ์๋ ํจ์๋ฅผ ์ถ๊ฐํฉ๋๋ค:
async function persistActor ( actor : APActor ): Promise < Actor | null > {
if ( actor . id == null || actor . inboxId == null ) {
logger . debug ( " Actor is missing required fields: {actor} " , { actor });
return null ;
}
return (
db
. prepare < unknown [], Actor >(
`
-- ์กํฐ ๋ ์ฝ๋๋ฅผ ์๋ก ์ถ๊ฐํ๊ฑฐ๋ ์ด๋ฏธ ์์ผ๋ฉด ๊ฐฑ์
INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (uri) DO UPDATE SET
handle = excluded.handle,
name = excluded.name,
inbox_url = excluded.inbox_url,
shared_inbox_url = excluded.shared_inbox_url,
url = excluded.url
WHERE
actors.uri = excluded.uri
RETURNING *
` ,
)
. get (
actor . id . href ,
await getActorHandle ( actor ),
actor . name ?. toString (),
actor . inboxId . href ,
actor . endpoints ?. sharedInbox ?. href ,
actor . url ?. href ,
) ?? null
);
}
์ ์ํ persistActor() ํจ์๋ ์ธ์๋ก ๋ค์ด์จ ์กํฐ ๊ฐ์ฒด์ ํด๋นํ๋ ๋ ์ฝ๋๋ฅผ actors ํ
์ด๋ธ์ ์ถ๊ฐํฉ๋๋ค. ์ด๋ฏธ ํ
์ด๋ธ์ ํด๋นํ๋ ๋ ์ฝ๋๊ฐ ์๋ค๋ฉด, ๋ ์ฝ๋๋ฅผ ๊ฐฑ์ ํฉ๋๋ค.
์์ ํจ์ on(Follow, ...) ๋ถ๋ถ์์ ๊ฐ์ ์ญํ ์ ํ๋ ์ฝ๋๋ฅผ persistActor() ํจ์๋ฅผ ์ฐ๊ฒ ๋ฐ๊ฟ๋๋ค:
federation
. setInboxListeners ( " /users/{identifier}/inbox " , " /inbox " )
. on ( Follow , async ( ctx , follow ) => {
// ... ์๋ต ...
if ( followingId == null ) {
logger . debug (
" Failed to find the actor to follow in the database: {object} " ,
{ object },
);
}
const followerId = ( await persistActor ( follower ))?. id ;
db . prepare (
" INSERT INTO follows (following_id, follower_id) VALUES (?, ?) " ,
). run ( followingId , followerId );
// ... ์๋ต ...
})
๋ฆฌํฉํฐ๋ง์ ๋๋์ผ๋ ์์ ํจ์ Accept(Follow) ์กํฐ๋นํฐ๋ฅผ ๋ฐ์์ ๋ ์ทจํ ํ๋์ ๊ตฌํํฉ๋๋ค:
. on ( Accept , async ( ctx , accept ) => {
const follow = await accept . getObject ();
if ( ! ( follow instanceof Follow )) return ;
const following = await accept . getActor ();
if ( ! isActor ( following )) return ;
const follower = follow . actorId ;
if ( follower == null ) return ;
const parsed = ctx . parseUri ( follower );
if ( parsed == null || parsed . type !== " actor " ) return ;
const followingId = ( await persistActor ( following ))?. id ;
if ( followingId == null ) return ;
db . prepare (
`
INSERT INTO follows (following_id, follower_id)
VALUES (
?,
(
SELECT actors.id
FROM actors
JOIN users ON actors.user_id = users.id
WHERE users.username = ?
)
)
` ,
). run ( followingId , parsed . identifier );
});
์ ํจ์ฑ์ ๊ฒ์ฌํ๋ ์ฝ๋๊ฐ ๊ธธ์ง๋ง ์์ฝํ๋ฉด Accept(Follow) ์กํฐ๋นํฐ์ ๋ด์ฉ์ผ๋ก๋ถํฐ ํ๋ก ์์ฒญ์ ๋ณด๋ธ ์กํฐ(follower)์ ํ๋ก ์์ฒญ์ ๋ฐ์ ์กํฐ(following)๋ฅผ ๊ตฌํ๊ณ follows ํ
์ด๋ธ์ ๋ ์ฝ๋๋ฅผ ์ถ๊ฐํ๋ ๊ฒ์
๋๋ค.
ํ
์คํธ
์ด์ ์ ๋์ํ๋์ง ํ์ธํด์ผ ํ๋๋ฐ, ๋ฌธ์ ๊ฐ ์์ต๋๋ค. ์๊น ํ๋ก ์์ฒญ์ ๋ณด๋์ ๋ ActivityPub.Academy ์ชฝ์์๋ ํ๋ก ์์ฒญ์ ์๋ฝํ๊ณ Accept(Follow) ์กํฐ๋นํฐ๋ฅผ ์ด๋ฏธ ๋ณด๋๊ธฐ ๋๋ฌธ์, ์ด ์ํ์์ ๋ค์ ํ ๋ฒ ํ๋ก ์์ฒญ์ ๋ณด๋ด๋ ๋ฌด์ํ๊ฒ ๋ฉ๋๋ค. ๋ฐ๋ผ์, ActivityPub.Academy์์ ๋ก๊ทธ์์์ ํ ๋ค ๋ค์ ์์ ๊ณ์ ์ ๋ง๋ค์ด์ ํ
์คํธ๋ฅผ ํด์ผ ํฉ๋๋ค.
ActivityPub.Academy์์ ์ ์์ ๊ณ์ ์ ๋ง๋ค์๋ค๋ฉด, fedify tunnel ๋ช
๋ น์ผ๋ก ๋ก์ปฌ ์๋ฒ๋ฅผ ๊ณต๊ฐ ์ธํฐ๋ท์ ๋
ธ์ถํ ์ํ์์, ์น ๋ธ๋ผ์ฐ์ ์์ https://temp-address.serveo.net/ (๋๋ฉ์ธ ์ด๋ฆ์ ์นํํ์ธ์) ํ์ด์ง๋ฅผ ๋ค์ด๊ฐ ActivityPub.Academy์ ์ ์์ ๊ณ์ ์ ํ๋ก ์์ฒญ์ ๋ณด๋
๋๋ค.
ํ๋ก ์์ฒญ์ด ์ ์ ์ก๋์๋ค๋ฉด, ์๊น์ ๋ง์ฐฌ๊ฐ์ง๋ก Activity Log ์ Follow ์กํฐ๋นํฐ๊ฐ ๋์ฐฉํ ํ ๋ต์ฅ์ผ๋ก Accept(Follow) ์กํฐ๋นํฐ๊ฐ ๋ฐ์ ๋ ๊ฒ์ ๋ณผ ์ ์์ ๊ฒ์
๋๋ค:
์์ง์ ํ๋ก์ ๋ชฉ๋ก์ ๊ตฌํํ์ง ์์์ผ๋ฏ๋ก, follows ํ
์ด๋ธ์ ๋ ์ฝ๋๊ฐ ์ ๋๋ก ๋ค์ด๊ฐ๋ ์ง์ ํ์ธ์ ํด ๋ด
์๋ค:
echo " SELECT * FROM follows WHERE follower_id = 1; " | sqlite3 -table microblog.sqlite3
์ฑ๊ณตํ๋ค๋ฉด ๋ค์๊ณผ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ๋์ฌ ๊ฒ์
๋๋ค (following_id ์นผ๋ผ์ ๋ ๊ฐ์ ๋ค์ ๋ค๋ฅผ ์ ์์ต๋๋ค):
following_id
follower_id
created
3
1
2024-09-02 14:11:17
ํ๋ก์ ๋ชฉ๋ก
์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ๊ฐ ํ๋กํ๋ ์กํฐ์ ๋ชฉ๋ก์ ํ์ํ๋ ํ์ด์ง๋ฅผ ๋ง๋ค์ด ๋ด
์๋ค.
๋จผ์ src/views.tsx ํ์ผ์ ์ด์ด <FollowingList> ์ปดํฌ๋ํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
export interface FollowingListProps {
following : Actor [];
}
export const FollowingList : FC < FollowingListProps > = ({ following }) => (
<>
< h2 > Following </ h2 >
< ul >
{ following . map (( actor ) => (
< li key ={ actor . id }>
< ActorLink actor ={ actor } />
</ li >
))}
</ ul >
</>
);
๊ทธ ๋ค์, src/app.tsx ํ์ผ์ ์ด์ด ์์ ์ ์ํ <FollowingList> ์ปดํฌ๋ํธ๋ฅผ importํฉ๋๋ค:
import {
FollowerList ,
FollowingList ,
Home ,
Layout ,
PostList ,
PostPage ,
Profile ,
SetupForm ,
} from " ./views.tsx " ;
๊ทธ๋ฆฌ๊ณ GET /users/{username}/following ์์ฒญ์ ๋ํ ํธ๋ค๋ฌ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
app . get ( " /users/:username/following " , async ( c ) => {
const following = db
. prepare < unknown [], Actor >(
`
SELECT following.*
FROM follows
JOIN actors AS followers ON follows.follower_id = followers.id
JOIN actors AS following ON follows.following_id = following.id
JOIN users ON users.id = followers.user_id
WHERE users.username = ?
ORDER BY follows.created DESC
` ,
)
. all ( c . req . param ( " username " ));
return c . html (
< Layout >
< FollowingList following ={ following } />
</ Layout >,
);
});
์ ๋๋ก ๊ตฌํ๋์๋์ง ํ์ธํ๊ธฐ ์ํด ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/users/johndoe/following ํ์ด์ง๋ฅผ ์ด์ด๋ด
์๋ค:
ํ๋ก์ ์
ํ๋ก์ ์๋ฅผ ๋ณด์ฌ์ฃผ๊ณ ์๋ ๊ฒ์ฒ๋ผ, ํ๋ก์ ์๋ ํ์ํด์ผ ํฉ๋๋ค.
src/views.tsx ํ์ผ์ ์ด์ด <Profile> ์ปดํฌ๋ํธ๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ์์ ํฉ๋๋ค:
export interface ProfileProps {
name : string ;
username : string ;
handle : string ;
following : number ;
followers : number ;
}
export const Profile : FC < ProfileProps > = ({
name ,
username ,
handle ,
following ,
followers ,
}) => (
<>
< hgroup >
< h1 >
< a href ={ ` /users/ ${ username } ` }>{ name }</ a >
</ h1 >
< p >
< span style = " user-select: all; " >{ handle }</ span > & middot ;{ " " }
< a href ={ ` /users/ ${ username } /following ` }>{ following } following </ a >{ " " }
& middot ;{ " " }
< a href ={ ` /users/ ${ username } /followers ` }>
{ followers === 1 ? " 1 follower " : ` ${ followers } followers ` }
</ a >
</ p >
</ hgroup >
</>
);
<PostPage> ์ปดํฌ๋ํธ๋ ๋ค์๊ณผ ๊ฐ์ด ์์ ํฉ๋๋ค:
export interface PostPageProps extends ProfileProps , PostViewProps {}
export const PostPage : FC < PostPageProps > = ( props ) => (
<>
< Profile
name ={ props . name }
username ={ props . username }
handle ={ props . handle }
following ={ props . following }
followers ={ props . followers }
/>
< PostView post ={ props . post } />
</>
);
๊ทธ๋ผ ์ด์ ์ค์ ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์กฐํํ์ฌ ํ๋ก์ ์๋ฅผ ๊ตฌํ๋ ์ฝ๋๋ฅผ ์ง์ผ ํฉ๋๋ค.
src/app.tsx ํ์ผ์ ์ด์ด GET /users/{username} ์์ฒญ์ ๋ํ ํธ๋ค๋ฌ๋ฅผ ๋ค์๊ณผ ๊ฐ์ด ์์ ํฉ๋๋ค:
app . get ( " /users/:username " , async ( c ) => {
// ... ์๋ต ...
if ( user == null ) return c . notFound ();
// biome-ignore lint/style/noNonNullAssertion: ์ธ์ ๋ ํ๋์ ๋ ์ฝ๋๋ฅผ ๋ฐํ
const { following } = db
. prepare < unknown [], { following : number }>(
`
SELECT count(*) AS following
FROM follows
JOIN actors ON follows.follower_id = actors.id
WHERE actors.user_id = ?
` ,
)
. get ( user . id ) ! ;
// ... ์๋ต ...
return c . html (
< Layout >
< Profile
name ={ user . name ?? user . username }
username ={ user . username }
handle ={ handle }
following ={ following }
followers ={ followers }
/>
< PostList posts ={ posts } />
</ Layout >,
);
});
GET /users/{username}/posts/{id} ์์ฒญ ํธ๋ค๋ฌ๋ ์์ ํฉ๋๋ค:
app . get ( " /users/:username/posts/:id " , ( c ) => {
// ... ์๋ต ...
if ( post == null ) return c . notFound ();
// biome-ignore lint/style/noNonNullAssertion: ์ธ์ ๋ ํ๋์ ๋ ์ฝ๋๋ฅผ ๋ฐํ
const { following , followers } = db
. prepare < unknown [], { following : number ; followers : number }>(
`
SELECT sum(follows.follower_id = ?) AS following,
sum(follows.following_id = ?) AS followers
FROM follows
` ,
)
. get ( post . actor_id , post . actor_id ) ! ;
return c . html (
< Layout >
< PostPage
name ={ post . name ?? post . username }
username ={ post . username }
handle ={ post . handle }
following ={ following }
followers ={ followers }
post ={ post }
/>
</ Layout >,
);
});
๋ค ์์ ๋์๋ค๋ฉด, ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/users/johndoe ํ์ด์ง๋ฅผ ์ด์ด ๋ด
์๋ค:
ํ์๋ผ์ธ
๋ง์ ๊ฒ๋ค์ ๊ตฌํํ์ง๋ง, ์์ง ๋ค๋ฅธ Mastodon ์๋ฒ์์ ์ด ๊ฒ์๋ฌผ์ด ๋ณด์ด์ง๋ ์๊ณ ์์ต๋๋ค. ์ฌํ๊น์ง์ ๊ณผ์ ์์ ์ง์ํ ์ ์๋ค์ํผ, ์ฐ๋ฆฌ๊ฐ ๊ฒ์๋ฌผ์ ์ธ ๋ Create(Note) ์กํฐ๋นํฐ๋ฅผ ๋ฐ์ ํ๋ ๊ฒ๊ณผ ๊ฐ์ด, ๋ค๋ฅธ ์๋ฒ๋ก๋ถํฐ Create(Note) ์กํฐ๋นํฐ๋ฅผ ์์ ํด์ผ ๋ค๋ฅธ Mastodon ์๋ฒ์์ ์ด ๊ฒ์๋ฌผ์ด ๋ณด์ด๊ฒ ๋ฉ๋๋ค.
๋ค๋ฅธ Mastodon ์๋ฒ์์ ๊ธ์ ์ฐ๋ฉด ๊ตฌ์ฒด์ ์ผ๋ก ์ด๋ค ์ผ์ด ์ผ์ด๋๋์ง ๋ณด๊ธฐ ์ํด, ActivityPub.Academy ์์ ์๋ก์ด ๊ฒ์๋ฌผ์ ์์ฑํด ๋ด
์๋ค:
Publish! ๋ฒํผ์ ๋๋ฌ ๊ฒ์๋ฌผ์ ์ ์ฅํ ๋ค, Activity Log ํ์ด์ง๋ก ๋ค์ด๊ฐ Create(Note) ์กํฐ๋นํฐ๊ฐ ๊ณผ์ฐ ์ ๋ฐ์ ๋์๋ ํ์ธํฉ๋๋ค:
์ด์ ์ด๋ ๊ฒ ๋ฐ์ ๋ Create(Note) ์กํฐ๋นํฐ๋ฅผ ์์ ํ๋ ์ฝ๋๋ฅผ ์ง์ผ ํฉ๋๋ค.
Create(Note) ์กํฐ๋นํฐ ์์
src/federation.ts ํ์ผ์ ์ด์ด Fedify๊ฐ ์ ๊ณตํ๋ Create ํด๋์ค๋ฅผ importํฉ๋๋ค:
import {
Accept ,
Create ,
Endpoints ,
Follow ,
Note ,
PUBLIC_COLLECTION ,
Person ,
Undo ,
createFederation ,
exportJwk ,
generateCryptoKeyPair ,
getActorHandle ,
importJwk ,
isActor ,
type Actor as APActor ,
type Recipient ,
} from " @fedify/fedify " ;
๊ทธ๋ฆฌ๊ณ ์์ ํจ ์ฝ๋์ on(Create, ...)๋ฅผ ์ถ๊ฐํฉ๋๋ค:
. on ( Create , async ( ctx , create ) => {
const object = await create . getObject ();
if ( ! ( object instanceof Note )) return ;
const actor = create . actorId ;
if ( actor == null ) return ;
const author = await object . getAttribution ();
if ( ! isActor ( author ) || author . id ?. href !== actor . href ) return ;
const actorId = ( await persistActor ( author ))?. id ;
if ( actorId == null ) return ;
if ( object . id == null ) return ;
const content = object . content ?. toString ();
db . prepare (
" INSERT INTO posts (uri, actor_id, content, url) VALUES (?, ?, ?, ?) " ,
). run ( object . id . href , actorId , content , object . url ?. href );
});
getAttribution() ๋ฉ์๋๋ฅผ ์ด์ฉํ์ฌ ๊ธ์ด์ด๋ฅผ ๊ตฌํ ๋ค, persistActor() ํจ์๋ฅผ ํตํด ํด๋น ์กํฐ๊ฐ ์์ง actors ํ
์ด๋ธ์ ์์ผ๋ฉด ์ถ๊ฐํฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ posts ํ
์ด๋ธ์ ์ ๋ ์ฝ๋๋ฅผ ํ๋ ์ถ๊ฐํฉ๋๋ค.
์ฝ๋๊ฐ ์ ์๋ํ๋์ง ํ์ธํ๊ธฐ ์ํด ๋ค์ ํ ๋ฒ ActivityPub.Academy ์ ๋ค์ด๊ฐ ๊ฒ์๋ฌผ์ ์์ฑํด ๋ด
์๋ค. Activity Log ๋ฅผ ์ด์ด Create(Note) ์กํฐ๋นํฐ๊ฐ ๋ฐ์ ๋์๋์ง ์ฒดํฌํ ๋ค, ์๋ ๋ช
๋ น์ผ๋ก posts ํ
์ด๋ธ์ ์ ๋ง ๋ ์ฝ๋๊ฐ ์ถ๊ฐ๋์๋ ํ์ธํฉ๋๋ค:
echo " SELECT * FROM posts WHERE actor_id != 1 " | sqlite3 -table microblog.sqlite3
์ ๋ง ๋ ์ฝ๋๊ฐ ์ถ๊ฐ๋์๋ค๋ฉด ์๋์ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ๋์์ผ ํฉ๋๋ค:
id
uri
actor_id
content
url
created
3
https://activitypub.academy/users/algusia_draneoll/statuses/113068684551948316
3
<p>Would it send a Create(Note) activity?</p>
https://activitypub.academy/@algusia_draneoll/113068684551948316
2024-09-02 15:33:32
์๊ฒฉ ๊ฒ์๋ฌผ ํ์
์, ์ด์ ์๊ฒฉ ๊ฒ์๋ฌผ์ posts ํ
์ด๋ธ์ ๋ ์ฝ๋๋ก ์ถ๊ฐํ์ผ๋, ์ด์ ๊ทธ ๋ ์ฝ๋๋ค์ ์ ํ์ํด ์ฃผ๋ ์ผ๋ง ๋จ์์ต๋๋ค. ํํ ใํ์๋ผ์ธใ์ด๋ผ๊ณ ๋ถ๋ฆฌ๋ ๊ธฐ๋ฅ์
๋๋ค.
๋จผ์ src/views.tsx ํ์ผ์ ์ด์ด <Home> ์ปดํฌ๋ํธ๋ฅผ ์์ ํฉ๋๋ค:
export interface HomeProps extends PostListProps {
user : User & Actor ;
}
export const Home : FC < HomeProps > = ({ user , posts }) => (
<>
{ /* ... ์๋ต ... */ }
< PostList posts ={ posts } />
</>
);
๊ทธ ๋ค, src/app.tsx ํ์ผ์ ์ด์ด GET / ์์ฒญ ํธ๋ค๋ฌ๋ฅผ ์์ ํฉ๋๋ค:
app . get ( " / " , ( c ) => {
// ... ์๋ต ...
if ( user == null ) return c . redirect ( " /setup " );
const posts = db
. prepare < unknown [], Post & Actor >(
`
SELECT actors.*, posts.*
FROM posts
JOIN actors ON posts.actor_id = actors.id
WHERE posts.actor_id = ? OR posts.actor_id IN (
SELECT following_id
FROM follows
WHERE follower_id = ?
)
ORDER BY posts.created DESC
` ,
)
. all ( user . id , user . id );
return c . html (
< Layout >
< Home user ={ user } posts ={ posts } />
</ Layout >,
);
});
์, ์ด์ ๋ค ๊ตฌํ๋์์ผ๋ ์น ๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/ ํ์ด์ง๋ฅผ ์ด์ด ํ์๋ผ์ธ์ ๊ฐ์ํฉ์๋ค:
์์ ๊ฐ์ด ์๊ฒฉ์์ ์์ฑํ ๊ฒ์๋ฌผ๊ณผ ๋ก์ปฌ์์ ์์ฑํ ๊ฒ์๋ฌผ์ด ์ต์ ์์ผ๋ก ์ ํ์๋๋ ๊ฒ์ ์ ์ ์์ต๋๋ค. ์ด๋ค๊ฐ์? ๋ง์์ ๋์๋์?
์ด ํํ ๋ฆฌ์ผ์์ ๊ตฌํํ ๊ฒ์ ์ด๊ฒ ์ ๋ถ์
๋๋ค. ์ด๊ฒ์ ๋ฐํ์ผ๋ก ์ฌ๋ฌ๋ถ๋ง์ ๋ง์ดํฌ๋ก๋ธ๋ก๊ทธ๋ฅผ ์์ฑ์ํค๋ ๊ฒ๋ ๊ฐ๋ฅํ ๊ฒ์
๋๋ค.
๊ฐ์ ํ ์
์ด ํํ ๋ฆฌ์ผ์ ํตํด ์์ฑํ ์ฌ๋ฌ๋ถ์ ๋ง์ดํฌ๋ก๋ธ๋ก๊ทธ๋ ์์ฝ๊ฒ๋ ์์ง ์ค์ฌ์ฉ์๋ ์ ํฉํ์ง ์์ต๋๋ค. ํนํ, ๋ณด์ ์ธก๋ฉด์์ ์ทจ์ฝ์ ์ด ๋ง์ด ์๊ธฐ ๋๋ฌธ์ ์ค์ ๋ก ์ฌ์ฉํ๋ ๊ฒ์ ์ํํ ์ ์์ต๋๋ค.
์ฌ๋ฌ๋ถ์ด ๋ง๋ ๋ง์ดํฌ๋ก๋ธ๋ก๊ทธ๋ฅผ ์ข ๋ ๋ฐ์ ์ํค๊ณ ์ถ์ ๋ถ๋ค์, ์๋ ๊ณผ์ ๋ค์ ์ง์ ํด๊ฒฐํด ๋ณด์
๋ ์ข์ ๊ฒ์
๋๋ค:
ํ์ฌ๋ ์๋ฌด๋ฐ ์ธ์ฆ์ด ์๊ธฐ ๋๋ฌธ์, ๋๊ตฌ๋ผ๋ URL๋ง ์๋ฉด ๊ธ์ ๊ฒ์ํ ์ ์์ต๋๋ค. ๋ก๊ทธ์ธ ๊ณผ์ ์ ์ถ๊ฐํ์ฌ ์ด๋ฅผ ๋ฐฉ์งํด ๋ณผ๊น์?
ํ์ฌ์ ๊ตฌํ์ ActivityPub์ ํตํด ๋ฐ์ Note ๊ฐ์ฒด ์์ ๋ค์ด ์๋ HTML์ ๊ทธ๋๋ก ์ถ๋ ฅํ๊ฒ ๋์ด ์์ต๋๋ค. ๋ฐ๋ผ์ ์
์์ ์ธ ActivityPub ์๋ฒ๊ฐ <script>while (true) alert('๋ฉ๋กฑ'); ๊ฐ์ HTML์ ํฌํจํ Create(Note) ์กํฐ๋นํฐ๋ฅผ ๋ณด๋ด๋ ๊ณต๊ฒฉ์ ํ ์ ์์ต๋๋ค. ์ด๋ฅผ XSS ์ทจ์ฝ์ ์ด๋ผ๊ณ ํฉ๋๋ค. ์ด๋ฌํ ์ทจ์ฝ์ ์ ์ด๋ป๊ฒ ๋ง์ ์ ์์๊น์?
SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ๋ค์ SQL์ ์คํํ์ฌ ์ฐ๋ฆฌ๊ฐ ๋ง๋ ์กํฐ์ ์ด๋ฆ์ ๋ฐ๊ฟ ๋ด
์๋ค:
UPDATE actors SET name = 'Renamed' WHERE id = 1;
์ด๋ ๊ฒ ์กํฐ์ ์ด๋ฆ์ ๋ฐ๊ฟจ์ ๋, ๋ค๋ฅธ Mastodon ์๋ฒ์์ ๋ฐ๋ ์ด๋ฆ์ด ์ ์ฉ๋ ๊น์? ์ ์ฉ๋์ง ์๋๋ค๋ฉด, ์ด๋ค ์กํฐ๋นํฐ๋ฅผ ๋ณด๋ด์ผ ๋ณ๊ฒฝ์ด ์ ์ฉ๋ ๊น์?
์กํฐ์ ํ๋กํ ์ฌ์ง์ ์ถ๊ฐํด ๋ด
์๋ค. ํ๋กํ ์ฌ์ง์ ์ถ๊ฐํ๋ ๋ฐฉ๋ฒ์ด ๊ถ๊ธํ๋ค๋ฉด, fedify lookup ๋ช
๋ น์ผ๋ก ์ด๋ฏธ ํ๋กํ ์ฌ์ง์ด ์๋ ์กํฐ๋ฅผ ์กฐํํด ๋ณด์ธ์.
๋ค๋ฅธ Mastodon ์๋ฒ์์ ์ด๋ฏธ์ง๊ฐ ์ฒจ๋ถ๋ ๊ฒ์๋ฌผ์ ์์ฑํด ๋ด
์๋ค. ์ฐ๋ฆฌ๊ฐ ๋ง๋ ํ์๋ผ์ธ์์๋ ๊ฒ์๋ฌผ์ ์ฒจ๋ถ๋ ์ด๋ฏธ์ง๊ฐ ๋ณด์ด์ง ์์ต๋๋ค. ์ด๋ป๊ฒ ํ๋ฉด ์ฒจ๋ถ๋ ์ด๋ฏธ์ง๋ฅผ ํ์ํ ์ ์์๊น์?
๊ฒ์๋ฌผ ๋ด์์ ๋ค๋ฅธ ์กํฐ๋ฅผ ๋ฉ์
ํ ์ ์๊ฒ ํด๋ด
์๋ค. ๋ฉ์
ํ ์๋ํํ
์๋ฆผ์ด ๊ฐ๋๋ก ํ๋ ค๋ฉด ์ด๋ป๊ฒ ํด์ผ ํ ๊น์? ActivityPub.Academy ์ Activity Log ๋ฅผ ํ์ฉํ์ฌ ๋ฐฉ๋ฒ์ ์ฐพ์๋ณด์ธ์.