#tutorial

XWiki SAS's avatar
XWiki SAS

@xwiki@xwiki.com

Migrating from Confluence doesnโ€™t have to be painful.

Our Product Owner, Stefana Nazare, shared a live demo showing how to migrate spaces, attachments, and macros to XWiki while keeping structure and permissions intact.

๐ŸŽฅ Watch the demo ๐Ÿ‘‡

Stefan Bohacek's avatar
Stefan Bohacek

@stefan@stefanbohacek.online

A quick, beginner-friendly walkthrough on how to make a nice-looking, accessible image gallery and host it for free with Neocities.

stefanbohacek.com/blog/making-

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: 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 ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € 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๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 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-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-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-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-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-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-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-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์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

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 ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ 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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” 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

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ 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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ 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 ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

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(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

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๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

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/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

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/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, 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์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ 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๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

Trixi Tomate

@tomatenfrau@troet.cafe

Ich bin auf der Suche nach einem , fรผr absolute im , am besten ein .
Konkret: wie geht der รœbergang zwischen den Runden, wenn man mit einem Fadenring angefangen hat, wie macht man einen Farbwechsel innerhalb einer Runde und wie vernรคht man die einzelnen Kรถrperteile?

Trixi Tomate

@tomatenfrau@troet.cafe

Ich bin auf der Suche nach einem , fรผr absolute im , am besten ein .
Konkret: wie geht der รœbergang zwischen den Runden, wenn man mit einem Fadenring angefangen hat, wie macht man einen Farbwechsel innerhalb einer Runde und wie vernรคht man die einzelnen Kรถrperteile?

Em :official_verified:'s avatar
Em :official_verified:

@Em0nM4stodon@infosec.exchange

New Privacy Guides tutorial :mastodon: โœ…
by me:

This article is now a series of two articles on Privacy and Security on Mastodon.

This tutorial is a step-by-step guide
to help Mastodon users make the most of the privacy and security features the platform offers.

The first article of this series was divided to better segment the information for readers who might prefer to read or share each part independently.

How To Improve Your Privacy and Security on Mastodon

Em :official_verified:'s avatar
Em :official_verified:

@Em0nM4stodon@infosec.exchange

New Privacy Guides tutorial :mastodon: โœ…
by me:

This article is now a series of two articles on Privacy and Security on Mastodon.

This tutorial is a step-by-step guide
to help Mastodon users make the most of the privacy and security features the platform offers.

The first article of this series was divided to better segment the information for readers who might prefer to read or share each part independently.

How To Improve Your Privacy and Security on Mastodon

Em :official_verified:'s avatar
Em :official_verified:

@Em0nM4stodon@infosec.exchange

New Privacy Guides tutorial :mastodon: โœ…
by me:

This article is now a series of two articles on Privacy and Security on Mastodon.

This tutorial is a step-by-step guide
to help Mastodon users make the most of the privacy and security features the platform offers.

The first article of this series was divided to better segment the information for readers who might prefer to read or share each part independently.

How To Improve Your Privacy and Security on Mastodon

Stefano Marinelli's avatar
Stefano Marinelli

@stefano@journal.bsd.cafe

<p>Tired of the Linux/Docker &#8220;monoculture&#8221; for WordPress? This article guides you step-by-step through the secure installation of WordPress on FreeBSD using BastilleBSD. Discover how jail separation, performance, and the versatility of ZFS offer a more robust and easily manageable environment, far from common vulnerabilities often linked to poorly maintained plugins. Get ready to make your site more secure and reliable.</p>

WordPress is one of the most widely used platforms for publishing content online. It’s often criticized as an insecure platform, but in reality WordPress itself is secure – it’s the many plugins, unmaintained or poorly developed, that generate significant vulnerabilities.

Many people host WordPress on Linux, often using Docker. While this is a valid approach, there are excellent alternatives – sometimes even better ones – for getting your WordPress site online in a secure, reliable, and updatable manner. The goal is to make the web a safer place and avoid the computing monoculture that increasingly pushes toward uniformity of solutions and setups – an attitude that I believe is harmful even when the solutions are open source.

For this type of setup, therefore, I’ll describe how to accomplish everything using FreeBSD. The jail separation, performance, and ZFS versatility – all reasons that support this choice. This guide will serve as a foundation – everything will work at the end, but it won’t cover all possible combinations or configurations.

We’ll be using BastilleBSD, which supports both ZFS and UFS. While FreeBSD’s base system has everything needed to create and run jails, BastilleBSD is incredibly useful for managing them. Since it’s written in shell script and has no database dependencies, management and backups are straightforward. Furthermore, moving jails becomes extremely simple – either by using the bastille command directly or by copying the files (or datasets, if you’re using ZFS).

BastilleBSD also supports templates, but for this tutorial, we’ll perform the operations manually to understand each step.

First, install Bastille:

pkg install bastille

Next, run the setup process:

bastille setup

Now, bootstrap the desired FreeBSD release:

bastille bootstrap 14.3-RELEASE update

With that, BastilleBSD is ready to go.

Creating the Jails

Now, let’s create the jail that will contain Apache, PHP, and WordPress:

bastille create apache 14.3-RELEASE 10.0.0.254 bastille0

Note: This command will only create and assign an IPv4 loopback address. For IPv6, the simplest solution is to assign an address for the jail directly to the host’s interface. To do this, note an available IPv6 address and assign it to the jail. For example, if the host’s network interface is vtnet0:

bastille edit apache

Add the following lines to the configuration file:

ip6 = new;ip6.addr = "vtnet0|2001:0DB8:1::443/64";

Restart the jail:

bastille restart apache

Next, let’s ensure that connections to the host’s ports 80 and 443 are redirected to the apache jail:

bastille rdr apache tcp 80 80bastille rdr apache tcp 443 443

Now, if using ZFS, let’s create a dedicated dataset for WordPress and mount it in the jail. The reason is simple: decoupling the Apache jail from the WordPress directory will allow for updates, rollbacks, etc. of the Apache jail without touching the WordPress files. I assure you that, in the long run, this approach will save many headaches.

zfs create zroot/wordpressbastille mount apache /zroot/wordpress/ /usr/local/www/wordpress nullfs rw 0 0

Now, let’s create the jail that will contain MariaDB:

bastille create mariadb 14.3-RELEASE 10.0.0.253 bastille0

Configuring the MariaDB Jail

Access the MariaDB jail’s console:

bastille console mariadb

Once inside, install the MariaDB server:

pkg install mariadb118-server

Enable and start the mysql-server service:

service mysql-server enableservice mysql-server start

Now, access the MySQL command line to set up the WordPress database:

mysql

Execute the following SQL commands (you should use more secure user, password, etc.):

CREATE USER wp@10.0.0.254 IDENTIFIED BY 'password';CREATE DATABASE wordpress;GRANT ALL PRIVILEGES ON wordpress.* TO wp@10.0.0.254;FLUSH PRIVILEGES;

Exit the MariaDB jail console to return to the host.

Configuring the Apache & PHP Jail

Now, let’s configure the apache jail. First, access its console:

bastille console apache

Inside the jail, install PHP and all the necessary extensions. We won’t install WordPress from the FreeBSD package – while it’s updated and maintained, I prefer to manage dependencies manually. It will be easier to manage updates in the long term, such as changing PHP versions, etc. At the time of writing this article, for example, the WordPress package depends on PHP 8.3 while I prefer to use 8.4.

pkg install php84 php84-bcmath php84-bz2 php84-calendar php84-ctype php84-curl php84-dom php84-exif php84-fileinfo php84-filter php84-ftp php84-gd php84-gettext php84-iconv php84-intl php84-mbstring php84-mysqli php84-opcache php84-pcntl php84-pdo php84-pdo_mysql php84-pecl-imagick php84-phar php84-posix php84-readline php84-session php84-shmop php84-simplexml php84-soap php84-sockets php84-sodium php84-tokenizer php84-xml php84-xmlreader php84-xmlwriter php84-xsl php84-zip php84-zlib

Next, install Apache:

pkg install apache24

Modify /usr/local/etc/apache24/httpd.conf to enable the required modules. Uncomment or add the following lines:

LoadModule mpm_event_module libexec/apache24/mod_mpm_event.so#LoadModule mpm_prefork_module libexec/apache24/mod_mpm_prefork.so#LoadModule mpm_worker_module libexec/apache24/mod_mpm_worker.so...LoadModule proxy_module libexec/apache24/mod_proxy.so...LoadModule proxy_fcgi_module libexec/apache24/mod_proxy_fcgi.so...LoadModule ssl_module libexec/apache24/mod_ssl.so...LoadModule rewrite_module libexec/apache24/mod_rewrite.so

Enable and start the Apache service:

service apache24 enableservice apache24 start

To optimize performance, enable PHP-FPM to listen on a socket. Modify the /usr/local/etc/php-fpm.d/www.conf file.

Comment out this line:

;listen = 127.0.0.1:9000

And add these lines:

listen = /tmp/php-fpm.socklisten.owner = wwwlisten.group = www

Now, we need to configure Apache to use PHP-FPM correctly. Create the file /usr/local/etc/apache24/Includes/php-fpm.conf and add the following:

<FilesMatch \.php$>  SetHandler proxy:unix:/tmp/php-fpm.sock</FilesMatch>

Restart Apache for the changes to take effect:

service apache24 graceful

It’s good practice to copy the production PHP template to the final, modifiable php.ini file, which can be customized with the required options and limits:

cp /usr/local/etc/php.ini-production /usr/local/etc/php.ini

Make any desired changes now (or later), then enable and start PHP-FPM:

service php_fpm enableservice php_fpm start

Installing WordPress

Navigate to the web server’s root directory:

cd /usr/local/www

Download and extract the latest version of WordPress:

fetch https://wordpress.org/latest.zipunzip latest.zip

Set the correct permissions:

chown -R www:www wordpress/

Now, create an Apache virtual host configuration file at /usr/local/etc/apache24/Includes/wordpress.conf – be sure to modify the “example.com” with your own real domain name:

<VirtualHost *:80>    ServerAdmin webmaster@example.com    ServerName example.com    ServerAlias www.example.com    DocumentRoot /usr/local/www/wordpress    <Directory /usr/local/www/wordpress>        DirectoryIndex index.php index.html index.htm        Options FollowSymLinks MultiViews        AllowOverride All        Require all granted    </Directory>    ErrorLog "/var/log/httpd-example.com-error.log"    CustomLog "/var/log/httpd-example.com-access.log" combined</VirtualHost>

Finally, restart Apache one more time:

service apache24 graceful

The server will now respond on port 80 with the specified hostname, but this is absolutely not optimal or recommended. It’s therefore appropriate to generate a certificate to enable HTTPS.

For a simple solution, I recommend installing certbot with the Apache plugin to manage everything through Apache:

pkg install py311-certbot py311-certbot-apache

In order to automatically renew the certificates, add this line to /etc/periodic.conf:

weekly_certbot_enable="YES"

And, once installed, generate the certificate:

certbot --apache -d example.com -d www.example.com

You can now proceed to connect to the specified URL and begin with the WordPress guided installation, remembering the authentication and database details (the host, in this example, is 10.0.0.253 – not localhost, since we installed it in a dedicated jail).

Congratulations, your site is installed and operational. Ready to receive content for publishing. It’s exposed on IPv4 and IPv6, with HTTPS (and automatic certificate renewal, managed directly by FreeBSD) and separated from the database.

Generally, I prefer to add an additional jail with a reverse proxy. This way it will be possible to install different software in different jails, ensuring that the reverse proxy “routes” requests correctly. I’ll explain this procedure in a future article.

While this is my inaugural FreeBSD post for the BSD Cafe Journal, I’ve actually written extensively on the topic for my own blog, https://it-notes.dragas.net

MacBook Pro, white ceramic mug,and black smartphone on table
ALT text detailsMacBook Pro, white ceramic mug,and black smartphone on table
La Loica's avatar
La Loica

@laloica@social.anartist.org

Bandera Palestina en Crochet
Este tutorial nace como un acto de memoria y solidaridad con el pueblo palestino. Desde mi espacio de creaciรณn textil, quise aportar una forma simbรณlica yย  de resistencia: tejiendo la bandera de Palestina.
A travรฉs de este video aprenderรกs a hacer una bandera tejida a crochet usando la tรฉcnica de intarsia en punto bajo (pb). Es una tรฉcnica accesible para quienes ya saben lo bรกs
laloica.noblogs.org/bandera-pa

Em :official_verified:'s avatar
Em :official_verified:

@Em0nM4stodon@infosec.exchange

New Privacy Guides article :mastodon: ๐Ÿ”’
by me:

While most social media rely on commercial models harvesting users' data to sell to advertisers,

Mastodon offers a human-centric alternative that doesn't seek profits from your data and attention.

This means better social connections, better controls, and better privacy!

The first part of this article discusses privacy and security on Mastodon.

The second part is a tutorial to guide you in making the most of Mastodon's security and privacy related features.

This tutorial includes how to:

โ€ข Enable multifactor authentication ๐Ÿ”‘๐Ÿ”‘

โ€ข Adjust privacy vs discovery ๐Ÿ‘€

โ€ข Select post visibility and access :neocat_box:

โ€ข Verify yourself :blobcatverified:

โ€ข Delete and back up your data :nes_fire:

โ€ข Block users and instances โ›”

โ€ข Opt out with hashtags #๏ธโƒฃ

โ€ข Move from one instance to another ๐Ÿš€

I hope this helps you making the most of what Mastodon has to offers! :awesome:

privacyguides.org/articles/202

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: 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 ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € 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๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 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-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-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-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-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-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-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-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์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

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 ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ 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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” 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

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ 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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ 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 ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

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(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

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๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

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/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

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/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, 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์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ 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๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: 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 ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € 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๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 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-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-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-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-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-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-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-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์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

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 ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ 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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” 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

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ 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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ 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 ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

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(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

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๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

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/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

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/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, 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์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ 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๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: 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 ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € 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๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 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-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-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-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-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-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-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-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์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

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 ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ 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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” 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

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ 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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ 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 ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

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(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

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๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

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/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

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/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, 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์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ 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๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hackers.pub

ํŒ

์ด ํŠœํ† ๋ฆฌ์–ผ์€ ๋‹ค์Œ ์–ธ์–ด๋กœ๋„ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค: 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 ํŠธ๋ฆฌ๊ฐ€ ๋Œ€์ž…๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํŒ

JSX๋Š” React ํ”„๋กœ์ ํŠธ์—์„œ ๋ฐœ๋ช…๋˜์–ด ๋„๋ฆฌ ์“ฐ์ด๊ธฐ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. JSX์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋‹ค๋ฉด, React ๋ฌธ์„œ์˜ JSX๋กœ ๋งˆํฌ์—… ์ž‘์„ฑํ•˜๊ธฐ ๋ฐ ์ค‘๊ด„ํ˜ธ๊ฐ€ ์žˆ๋Š” JSX ์•ˆ์—์„œ JavaScript ์‚ฌ์šฉํ•˜๊ธฐ ์„น์…˜์„ ์ฝ์–ด ๋ณด์„ธ์š”.

๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€

์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์ธ ๊ฐœ๋ฐœ์— ๋Œ์ž…ํ•ฉ์‹œ๋‹ค.

๊ฐ€์žฅ ๋จผ์ € ๋งŒ๋“ค ๊ฒƒ์€ ๋ฐ”๋กœ ๊ณ„์ • ์ƒ์„ฑ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ๊ฒŒ์‹œ๋ฌผ๋„ ์˜ฌ๋ฆฌ๊ณ  ๋‹ค๋ฅธ ๊ณ„์ •์„ ํŒ”๋กœ ํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ฃ . ๋ณด์ด๋Š” ๊ฒƒ๋ถ€ํ„ฐ ๋งŒ๋“ค์–ด ๋ด…์‹œ๋‹ค.

๋จผ์ € 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๋Š” ์—ฌ๋Ÿฌ๋ถ„์ด ์ž…๋ ฅํ•œ ์•„์ด๋””์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๊ฒ ์ฃ ):

id username
1 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-v1.5 ํ˜•์‹์—์„œ Ed25519 ํ˜•์‹์œผ๋กœ ์ดํ–‰ํ•˜๊ณ  ์žˆ๋Š” ์ค‘์ด๋ผ๊ณ  ์•Œ๊ณ  ๊ณ„์‹œ๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค. ์–ด๋–ค ์†Œํ”„ํŠธ์›จ์–ด๋Š” RSA-PKCS-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-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-v1.5 ํ˜•์‹๊ณผ Ed25519 ํ˜•์‹์˜ ํ‚ค๋ฅผ ๋ชจ๋‘ ์ƒ์„ฑํ–ˆ๋˜ ๊ฒƒ๊ณผ ๋น„์Šทํ•œ ์ด์œ ๋กœ, ์—ฌ๋Ÿฌ ์†Œํ”„ํŠธ์›จ์–ด์™€์˜ ํ˜ธํ™˜์„ฑ์„ ์œ„ํ•ด ๋‘ ์†์„ฑ ๋ชจ๋‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ž์„ธํžˆ ๋ณด๋ฉด, ๋ ˆ๊ฑฐ์‹œ ์†์„ฑ์ธ publicKey์—๋Š” ๋ ˆ๊ฑฐ์‹œ ํ‚ค ํ˜•์‹์ธ RSA-PKCS-v1.5 ํ‚ค๋งŒ ๋“ฑ๋กํ•˜๊ณ  ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค (๋ฐฐ์—ด์˜ ์ฒซ ๋ฒˆ์งธ ํ•ญ๋ชฉ์— RSA-PKCS-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-v1.5 ํ˜•์‹์˜ CryptographicKey ๊ฐ์ฒด ํ•˜๋‚˜๊ฐ€, assertionMethods ์†์„ฑ์—๋Š” RSA-PKCS-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์— ๋“ค์–ด๊ฐ€ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰์„ ํ•ด ๋ณด์„ธ์š”:

Mastodon์—์„œ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์œ„์™€ ๊ฐ™์ด ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๋ณด์ด๋ฉด ์ •์ƒ์ž…๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์—์„œ ์•กํ„ฐ์˜ ์ด๋ฆ„์„ ๋ˆŒ๋Ÿฌ์„œ ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐˆ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค:

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 ์„œ๋ฒ„์ธ๋ฐ, ํด๋ฆญ ํ•œ ๋ฒˆ์œผ๋กœ ์ž„์‹œ ๊ณ„์ •์„ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ActivityPub.Academy ์ฒซ ํŽ˜์ด์ง€

๊ฐœ์ธ ์ •๋ณด ๋ณดํ˜ธ ์ •์ฑ…์— ๋™์˜ํ•œ ๋’ค ๋“ฑ๋กํ•˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ƒˆ ๊ณ„์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ฑ๋œ ๊ณ„์ •์€ ๋ฌด์ž‘์œ„๋กœ ์ง€์–ด์ง„ ์ด๋ฆ„๊ณผ ํ•ธ๋“ค์„ ๊ฐ–๊ฒŒ ๋˜๋ฉฐ, ํ•˜๋ฃจ๊ฐ€ ์ง€๋‚˜๋ฉด ์•Œ์•„์„œ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค. ๋Œ€์‹ , ๊ณ„์ •์€ ๋˜ ์ƒˆ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋กœ๊ทธ์ธ์ด ๋˜๊ณ  ๋‚˜๋ฉด ํ™”๋ฉด์˜ ์ขŒ์ƒ๋‹จ์— ์œ„์น˜ํ•œ ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค์„ ๋ถ™์—ฌ๋„ฃ๊ณ  ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ•ธ๋“ค๋กœ ๊ฒ€์ƒ‰ํ•œ ๊ฒฐ๊ณผ

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๊ฐ€ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ํ‘œ์‹œ๋˜๋ฉด, ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š” ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์šฐ์ธก ๋ฉ”๋‰ด์—์„œ Activity Log๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

๊ทธ๋Ÿผ ๋ฐฉ๊ธˆ ํŒ”๋กœ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฆ„์œผ๋กœ์จ ActivityPub.Academy ์„œ๋ฒ„์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์ˆ˜์‹ ํ•จ์œผ๋กœ Follow ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜์—ˆ๋‹ค๋Š” ํ‘œ์‹œ๊ฐ€ ๋ณด์ž…๋‹ˆ๋‹ค. ์šฐํ•˜๋‹จ์˜ show source๋ฅผ ๋ˆ„๋ฅด๋ฉด ์•กํ‹ฐ๋น„ํ‹ฐ์˜ ๋‚ด์šฉ๊นŒ์ง€ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

Activity Log์—์„œ 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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ž˜ ๋„์ฐฉํ–ˆ๋‹ค๋ฉด, ์•„๋ž˜์™€ ๊ฐ™์ด ํ‘œ์‹œ๋  ๊ฒƒ์ž…๋‹ˆ๋‹ค:

Activity Log์— ํ‘œ์‹œ๋œ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ

์ž, ์ด๋ ‡๊ฒŒ ์—ฌ๋Ÿฌ๋ถ„์€ ์ฒ˜์Œ์œผ๋กœ ActivityPub์„ ํ†ตํ•œ ์ƒํ˜ธ์ž‘์šฉ์„ ๊ตฌํ˜„ํ•ด๋ƒˆ์Šต๋‹ˆ๋‹ค!

ํŒ”๋กœ ์ทจ์†Œ

๋‹ค๋ฅธ ์„œ๋ฒ„์˜ ์•กํ„ฐ๊ฐ€ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ํŒ”๋กœํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์ทจ์†Œํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ์š”? ํ•œ ๋ฒˆ ActivityPub.Academy์—์„œ ์‹œํ—˜ํ•ด ๋ด…์‹œ๋‹ค. ์•„๊นŒ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ ActivityPub.Academy ๊ฒ€์ƒ‰์ฐฝ์— ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•˜์—ฌ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ

์ž์„ธํžˆ ๋ณด๋ฉด ์•กํ„ฐ ์ด๋ฆ„ ์˜ค๋ฅธ์ชฝ์— ์žˆ๋˜ ํŒ”๋กœ ๋ฒ„ํŠผ ์ž๋ฆฌ์— ์–ธํŒ”๋กœ(unfollow) ๋ฒ„ํŠผ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ํŒ”๋กœ๋ฅผ ํ•ด์ œํ•œ ๋’ค, Activity Log์— ๋“ค์–ด๊ฐ€์„œ ์–ด๋–ค ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ์ „์†ก๋˜๋‚˜ ํ™•์ธํ•ด ๋ด…์‹œ๋‹ค:

๋ฐœ์‹ ๋œ Undo(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” 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

์–ธํŒ”๋กœ ์š”์ฒญ์ด ์ž˜ ์ฒ˜๋ฆฌ๋˜์—ˆ๋‹ค๋ฉด, ๋ ˆ์ฝ”๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์œผ๋ฏ€๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค:

count(*)
0

ํŒ”๋กœ์›Œ ๋ชฉ๋ก

๋งค๋ฒˆ ํŒ”๋กœ์›Œ ๋ชฉ๋ก์„ 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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ์˜ ํ”„๋กœํ•„์„ ๋ณด๋ฉด ์•„๋งˆ๋„ ์ด์ƒํ•œ ์ ์„ ๋ˆˆ์น˜ ์ฑŒ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

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์—์„œ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  ์•กํ„ฐ๋ฅผ ์กฐํšŒํ•ด ๋ณผ๊นŒ์š”? ํ•˜์ง€๋งŒ ๊ทธ ๊ฒฐ๊ณผ๋Š” ์ข€ ์‹ค๋ง์Šค๋Ÿฌ์šธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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์—์„œ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก &lt;, &gt;์™€ ๊ฐ™์€ 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 ์˜ค๋ฅ˜๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค:

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(์—ฌ๋Ÿฌ๋ถ„์˜ ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์œผ๋กœ ์น˜ํ™˜ํ•˜์„ธ์š”)์„ ์ณ๋ด…์‹œ๋‹ค:

Mastodon ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋ณด์ธ๋‹ค.

์ด๋ฒˆ์—๋Š” ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ์ œ๋Œ€๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๋ฌผ์ด ๋‚˜์˜ค๋„ค์š”!

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๋ฅผ ์‚ดํŽด๋ด…์‹œ๋‹ค:

์ˆ˜์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ž˜ ๋“ค์–ด์™”๋„ค์š”. ๊ทธ๋Ÿผ ActivityPub.Academy์—์„œ ํƒ€์ž„๋ผ์ธ์„ ์‚ดํŽด๋ด…์‹œ๋‹ค:

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/ ํŽ˜์ด์ง€๋ฅผ ์—ด์–ด ๋ด…์‹œ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์ƒ๊ธด ์ฒซ ํ™”๋ฉด

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/(๋„๋ฉ”์ธ ์ด๋ฆ„์€ ์น˜ํ™˜ํ•˜์„ธ์š”) ํŽ˜์ด์ง€๋ฅผ ๋“ค์–ด๊ฐ‘๋‹ˆ๋‹ค:

ํŒ”๋กœ ์š”์ฒญ UI๊ฐ€ ์žˆ๋Š” ์ฒซ ํ™”๋ฉด

ํŒ”๋กœ ์š”์ฒญ ์ž…๋ ฅ์ฐฝ์— ํŒ”๋กœํ•  ์•กํ„ฐ์˜ ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์‰ฌ์šด ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ActivityPub.Academy์˜ ์•กํ„ฐ๋ฅผ ์ž…๋ ฅํ•˜๋„๋ก ํ•ฉ์‹œ๋‹ค. ์ฐธ๊ณ ๋กœ, ActivityPub.Academy์—์„œ ๋กœ๊ทธ์ธ ๋œ ์ž„์‹œ ๊ณ„์ •์˜ ํ•ธ๋“ค์€ ์ž„์‹œ ๊ณ„์ •์˜ ์ด๋ฆ„์„ ํด๋ฆญํ•˜์—ฌ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์— ๋“ค์–ด๊ฐ€๋ฉด ์ด๋ฆ„ ๋ฐ”๋กœ ์•„๋ž˜์—์„œ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ๊ณ„์ • ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ƒ์— ๋ณด์ด๋Š” ์—ฐํ•ฉ์šฐ์ฃผ ํ•ธ๋“ค

๋‹ค์Œ๊ณผ ๊ฐ™์ด ActivityPub.Academy์˜ ์•กํ„ฐ ํ•ธ๋“ค์„ ์ž…๋ ฅํ•œ ๋’ค, Follow ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ ์•กํ„ฐ๋กœ ํŒ”๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ์ค‘

๊ทธ๋ฆฌ๊ณ  ActivityPub.Academy์˜ Activity Log๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

ActivityPub.Academy์˜ Activity Log

Activity Log์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์ „์†กํ•œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€, ActivityPub.Academy๋กœ๋ถ€ํ„ฐ ์ „์†ก๋œ ๋‹ต์žฅ์ธ Accept(Follow) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

ActivityPub.Academy์˜ ์•Œ๋ฆผ ํŽ˜์ด์ง€๋กœ ๊ฐ€๋ฉด ์‹ค์ œ๋กœ ํŒ”๋กœ ์š”์ฒญ์ด ๋„์ฐฉํ•œ ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ฐœ์‹ ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค:

์ˆ˜์‹ ๋œ Follow ์•กํ‹ฐ๋น„ํ‹ฐ์™€ ๋ฐœ์‹ ๋œ Accept(Follow) ์•ก๋น„๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์•„์ง์€ ํŒ”๋กœ์ž‰ ๋ชฉ๋ก์„ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์œผ๋ฏ€๋กœ, 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์—์„œ ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑํ•ด ๋ด…์‹œ๋‹ค:

ActivityPub.Academy์—์„œ ์ƒˆ ๊ฒŒ์‹œ๋ฌผ์„ ์ž‘์„ฑ์ค‘

Publish! ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๊ฒŒ์‹œ๋ฌผ์„ ์ €์žฅํ•œ ๋’ค, Activity Log ํŽ˜์ด์ง€๋กœ ๋“ค์–ด๊ฐ€ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๊ณผ์—ฐ ์ž˜ ๋ฐœ์‹ ๋˜์—ˆ๋‚˜ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค:

๋ฐœ์‹ ๋œ Create(Note) ์•กํ‹ฐ๋น„ํ‹ฐ๊ฐ€ ๋ณด์ด๋Š” Activity Log

์ด์ œ ์ด๋ ‡๊ฒŒ ๋ฐœ์‹ ๋œ 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๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฉ๋ฒ•์„ ์ฐพ์•„๋ณด์„ธ์š”.

Kevin Bowen ๐Ÿญ's avatar
Kevin Bowen ๐Ÿญ

@kevinbowen@hachyderm.io

As a bit of a diversion, I somehow ended up working through the this weekend.

I don't remotely consider myself much of a programmer. Possibly, a bit of a code janitor. I was just looking for a bit of an introduction to an unfamiliar language.

I have to say, has some really clear, clean, and helpful for a beginner wanting to get started with the language. I'm impressed.

Between the interactive tour(go.dev/tour/welcome/1) and the Getting Started tutorial(go.dev/doc/tutorial/), I honestly had fun dipping my toes into Go.

go.dev/learn/

Additional resources:
gobyexample.com/

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@hollo.social

If you're interested in building your own server but don't know where to start, I recommend checking out 's Creating your own federated microblog. It provides a comprehensive, step-by-step guide that walks you through building a fully functional federated application. Perfect for developers who want to dive into the !

Nicolas Borboรซn's avatar
Nicolas Borboรซn

@nborboen@social.epfl.ch

What to learn more about the and ? This tutorial (fedify.dev/tutorial/microblog) from @fedify is a really good starter. It explains how to implement a really simple server and see how communication exchange are done with AP.

https://fedify.dev/ website
ALT text detailshttps://fedify.dev/ website
https://github.com/fedify-dev/fedify
ALT text detailshttps://github.com/fedify-dev/fedify
Nicolas Borboรซn's avatar
Nicolas Borboรซn

@nborboen@social.epfl.ch

What to learn more about the and ? This tutorial (fedify.dev/tutorial/microblog) from @fedify is a really good starter. It explains how to implement a really simple server and see how communication exchange are done with AP.

https://fedify.dev/ website
ALT text detailshttps://fedify.dev/ website
https://github.com/fedify-dev/fedify
ALT text detailshttps://github.com/fedify-dev/fedify
Nicolas Borboรซn's avatar
Nicolas Borboรซn

@nborboen@social.epfl.ch

What to learn more about the and ? This tutorial (fedify.dev/tutorial/microblog) from @fedify is a really good starter. It explains how to implement a really simple server and see how communication exchange are done with AP.

https://fedify.dev/ website
ALT text detailshttps://fedify.dev/ website
https://github.com/fedify-dev/fedify
ALT text detailshttps://github.com/fedify-dev/fedify
Nicolas Borboรซn's avatar
Nicolas Borboรซn

@nborboen@social.epfl.ch

What to learn more about the and ? This tutorial (fedify.dev/tutorial/microblog) from @fedify is a really good starter. It explains how to implement a really simple server and see how communication exchange are done with AP.

https://fedify.dev/ website
ALT text detailshttps://fedify.dev/ website
https://github.com/fedify-dev/fedify
ALT text detailshttps://github.com/fedify-dev/fedify
Nicolas Borboรซn's avatar
Nicolas Borboรซn

@nborboen@social.epfl.ch

What to learn more about the and ? This tutorial (fedify.dev/tutorial/microblog) from @fedify is a really good starter. It explains how to implement a really simple server and see how communication exchange are done with AP.

https://fedify.dev/ website
ALT text detailshttps://fedify.dev/ website
https://github.com/fedify-dev/fedify
ALT text detailshttps://github.com/fedify-dev/fedify
Nicolas Borboรซn's avatar
Nicolas Borboรซn

@nborboen@social.epfl.ch

What to learn more about the and ? This tutorial (fedify.dev/tutorial/microblog) from @fedify is a really good starter. It explains how to implement a really simple server and see how communication exchange are done with AP.

https://fedify.dev/ website
ALT text detailshttps://fedify.dev/ website
https://github.com/fedify-dev/fedify
ALT text detailshttps://github.com/fedify-dev/fedify
Volla's avatar
Volla

@volla@mastodon.social

Volla OS tip

โœ… Block dangerous or annoying Internet addresses from being accessed!

โ—๏ธImportantโ—๏ธ While the security mode is activated, the settings cannot be changed๐Ÿ›ก

volla.online

jools

@jools@missocial.de

Nachdem ich nun eine PeerTube-Instanz gefunden habe, dachte ich mir, ich nehme doch mal schnell ein kurzes Video auf fรผr die neuen User, die sich das erste Mal bei einer bestehenden Friendica-Instanz registrieren und dann vor einer leeren Timeline stehen...


Das Video gibt es bei clip.place, einer PeerTube-Instanz und darf gerne geteilt werden.


Link: Friendica - Timeline mit Inhalten fรผllen


, , , , , , ,

jools

@jools@missocial.de

Nachdem ich nun eine PeerTube-Instanz gefunden habe, dachte ich mir, ich nehme doch mal schnell ein kurzes Video auf fรผr die neuen User, die sich das erste Mal bei einer bestehenden Friendica-Instanz registrieren und dann vor einer leeren Timeline stehen...


Das Video gibt es bei clip.place, einer PeerTube-Instanz und darf gerne geteilt werden.


Link: Friendica - Timeline mit Inhalten fรผllen


, , , , , , ,

Volla's avatar
Volla

@volla@mastodon.social

Volla OS tip

โœ… Create calendar entries via the springboard

The text field on the springboard anticipates what you want to do. To do this, you must enter a future time, e.g: Friday morning or tomorrow morning, optionally with a time.

The system will suggest โ€œAdd to calendarโ€ and you can complete the entry in the calendar by tapping on it.

freespiritlinux69 :fedi:'s avatar
freespiritlinux69 :fedi:

@freespiritlinux69@fedi.at

Schรถnen guten Morgen,

Ich habe mich entschieden, ein kleines Tutorial รผber CLI-Befehle unter Linux zu erstellen.
CLI steht fรผr Command Line Interface, was auf Deutsch als Befehlszeilenschnittstelle bezeichnet wird. Es handelt sich um eine textbasierte Benutzeroberflรคche, die es Benutzern ermรถglicht, mit einem Computer oder einem Betriebssystem zu interagieren, indem sie Befehle in Form von Text eingeben.
In Linux kann das sogenannte Terminal zur Eingabe von CLI-Befehlen verwendet werden.

Bitte teilt das mit eurer , damit mehr Menschen darauf aufmerksam werden. Dieses Tutorial richtet sich an Anfรคnger, die das Terminal unter Linux nicht als furchterregendes Monster betrachten, sondern effizient damit arbeiten mรถchten.

DANKESCHร–N

Der Link fรผhrt direkt zum Thread mit dem Tutorial und kann bei Bedarf gespeichert werden.

fedi.at/@freespiritlinux69/114

Crystal Huff (they/them)'s avatar
Crystal Huff (they/them)

@crystalvisits@wrong.tools

Okay, I read this article about people doing satirical MAGA makeup tutorials: wwd.com/pop-culture/culture-ne

And I watched the TikTok they were talking about, which was amazing and vicious: tiktok.com/@itssuzannelambert/

But to be clear: SailorJ (Jahkara Smith) did feminist tutorials about how to get a man with makeup all before yโ€™all could crawl. youtube.com/watch?v=HJ4zzkI4Ct

Box464's avatar
Box464

@box464@mastodon.social

Just published a guide on setting up Snac on an Ubuntu VM using NGINX Proxy Manager. Snac is an incredibly lightweight server. A true nom nom among fediverse platforms.

If you're curious about minimal fediverse instances, check it out:

box464.com/posts/snac-activity

Box464's avatar
Box464

@box464@mastodon.social

Just published a guide on setting up Snac on an Ubuntu VM using NGINX Proxy Manager. Snac is an incredibly lightweight server. A true nom nom among fediverse platforms.

If you're curious about minimal fediverse instances, check it out:

box464.com/posts/snac-activity

Box464's avatar
Box464

@box464@mastodon.social

Just published a guide on setting up Snac on an Ubuntu VM using NGINX Proxy Manager. Snac is an incredibly lightweight server. A true nom nom among fediverse platforms.

If you're curious about minimal fediverse instances, check it out:

box464.com/posts/snac-activity

Box464's avatar
Box464

@box464@mastodon.social

Just published a guide on setting up Snac on an Ubuntu VM using NGINX Proxy Manager. Snac is an incredibly lightweight server. A true nom nom among fediverse platforms.

If you're curious about minimal fediverse instances, check it out:

box464.com/posts/snac-activity

Kai๐Ÿ”ธ's avatar
Kai๐Ÿ”ธ

@kaimuri@mastodon.art

The tutorial is live :)
A follow-up to my bard animation. Since so many people asked how I created the watercolor effect in Blender, I put together a tutorial breaking down all the techniques. Let me know what you think :)

youtube.com/watch?v=_qZ51VLE3A

Kai๐Ÿ”ธ's avatar
Kai๐Ÿ”ธ

@kaimuri@mastodon.art

The tutorial is live :)
A follow-up to my bard animation. Since so many people asked how I created the watercolor effect in Blender, I put together a tutorial breaking down all the techniques. Let me know what you think :)

youtube.com/watch?v=_qZ51VLE3A

Ivan Agosto ๐Ÿ‡ฒ๐Ÿ‡ฝ's avatar
Ivan Agosto ๐Ÿ‡ฒ๐Ÿ‡ฝ

@agosto182@mast.lat

Subi a mi blog un tutorial para principiantes con .
Esta vez es para darle un mejor "look" y ahora nuestro Emacs luzca mucho mejor.


blog.iagosto.dev/entradas/pers

Stefano Marinelli's avatar
Stefano Marinelli

@stefano@bsd.cafe

Improving snac Performance with Nginx Proxy Cache

it-notes.dragas.net/2025/01/29

Paul Orlando Caggegi's avatar
Paul Orlando Caggegi

@pcaggegi@mastodon.social

My new course, TURNAROUND is available to @cgcookie members to access right now!

youtu.be/bjS7xyuOML4

Paul Orlando Caggegi's avatar
Paul Orlando Caggegi

@pcaggegi@mastodon.social

If you're a @cgcookie member, you'll have access to CONCEPT right now!

youtube.com/watch?si=rI87OZRFR

MDZG (Markdown Zen Garden)'s avatar
MDZG (Markdown Zen Garden)

@laravista@mastodon.uno

Blog | (Markdown Zen Garden) โฌ‡๏ธ๐Ÿง˜

: Create your with MDZG on
monastic.neocities.org/blog/20

Terence Edenโ€™s Blog's avatar
Terence Edenโ€™s Blog

@blog@shkspr.mobi

An Easy Guide To BlueSky Verification

The new Twitter-Wannabe BlueSky has an interesting approach to verifying accounts. Rather than you sending in your passport, or paying a 3rd party, or bribing an employee - you can self-verify for free!

This opens up verification to small organisations, individuals, and anyone who wants to prove who they are. Brilliant!

Verification means that your @username will change to @Your.Website.com - this means that everyone can see your BlueSky account is owned by that specific website.

Here are some organisations and people at risk of impersonation who have already done this:

There is an easy way to get verified and a hard way. Let's do the easy way!

1) Sign Up For BlueSky

Sign up and register a username. This can be anything you want. For example, I registered edent.bsky.social

2) Change Your User ID

Follow these steps:

  1. Visit https://bsky.app/settings
  2. Scroll down and select "Change Handle"
  3. Click "I have my own domain"
  4. Select "No DNS Panel". The screen should look like this:
    • Change Handle screen.
  5. Type in the domain name you want to verify
  6. Click "Copy File Contents"

Keep this web page open.

3) Copy and Save Your DID

On your clipboard, you will have a bit of text which looks like this did:plc:dip7ueksh627fxacagfrdyz2

Save it in a text file called atproto-did

Screenshot of a text editor.

It is very important that the file doesn't end with .txt - it must be called atproto-did and nothing else.

The file should only contain the text you copied. Nothing else.

4) Upload The File To Your Website

This is the only technical bit of the process. You need the ability to upload a file to your website. I don't know whether you use FTP, a control panel, or email things to the person who manages your site.

You need to save the atproto-did file in a folder called /.well-known/

If that folder doesn't exist, create it. The folder name must be typed exactly like that, with the dot at the start.

You can check it has worked by visiting YourWebsite.com/.well-known/atproto-did

If you can see your DID, it worked!

5) Change Your Username

Go back to the "Change Handle" web page you opened in Step 2.

Click "Verify Text File" and then "Update".

6) That's It!

Feel free to share this guide with people and organisations who want to get verified on BSky.

Leave a comment if you found it useful or want me to clarify something.

Karsten Schmidt's avatar
Karsten Schmidt

@toxi@mastodon.thi.ng

Yesterday, one year ago... (Still wondering how many people actually have read or tried out any of these)

mastodon.thi.ng/@toxi/11134859

Stefan Bohacek's avatar
Stefan Bohacek

@stefan@stefanbohacek.online

In my recent tutorial, I showed you how to make a Mastodon bot that posts data from a spreadsheet.

In this follow up, let's add a chart!

stefanbohacek.com/blog/making-

Brook Miles's avatar
Brook Miles

@brook@sunny.garden

Five years ago I started working on a tutorial, Game Programming Basics in Lua and Love2D...

I'm relieved to announce that I have now finished Part I (the Lua-only part) :yell:

It's aimed at beginners, and assumes no previous programming experience.

I started writing it based on the experience of an artist friend who took it upon themselves to learn programming as well, in the hopes that it could serve as a guide for people doing something similar, or anyone looking for an approachable path into game development.

brookmiles.ca/game-programming

Tomรกลก's avatar
Tomรกลก

@prahou@merveilles.town

I was asked to promote @FediverseSymbol in my work. So I had a naked friend do a short tutorial on how to paint the symbol itself. I hope it doesn't get lost in the federated timeline.

symbol.fediverse.info/

โ‚

They didn't pay me, but their manifesto website works in links2, so I figured I'll do it.

"Like any responsible artist, I stay out of politics.

So today I'll be teaching you how to draw the newly proposed fediverse logo!"

It's three asterisks: โ‚

Child is afraid to go to sleep. Father says: "Artists aren't real, they can't eat you, cub.

A linux supremacist reviews a recent distro: "I thought it was the last good linux... but if you scroll down... THE GAY FLAG! It's over bros..."

A rabbit is hacking you. Just as you're reading this. It's in your server. Right now.

Glowboy stands tall: "I am a free speech absolutist. Please, post anything on my instance!"

Smoker calls X fascist. 

A bro accuse a little duckling of being an APPLE HATER and a LIAR. The duckling continues: "I also don't walk around with all my lifesavings in a pocket."

A kid with a star of david on its chest says: "dad?" Dad replies: "It's not your fault."

Two green boys converse. One says: "We should kill big tech." The other replies: "Like.. the CEO's? Levi, that's going to be really hard..."

Underground person with a gopher says: "I don't even use html."

A person is offering a boost to a girl in a short skirt and kneesocks. 

"Mental Outlaw is a fed." said a person to another one, who's wearing a parody of an openbsd t-shirt that says "OPEN BASED" They reply: "But the merch is nice." The first finishes: "No it is not."

VOTE ANARCHY

Well what do you think, asks a person wearing cat ears. 

SUPPORT TRANS RIGHTS.

SAVE DEMOCRACY!
ALT text details"Like any responsible artist, I stay out of politics. So today I'll be teaching you how to draw the newly proposed fediverse logo!" It's three asterisks: โ‚ Child is afraid to go to sleep. Father says: "Artists aren't real, they can't eat you, cub. A linux supremacist reviews a recent distro: "I thought it was the last good linux... but if you scroll down... THE GAY FLAG! It's over bros..." A rabbit is hacking you. Just as you're reading this. It's in your server. Right now. Glowboy stands tall: "I am a free speech absolutist. Please, post anything on my instance!" Smoker calls X fascist. A bro accuse a little duckling of being an APPLE HATER and a LIAR. The duckling continues: "I also don't walk around with all my lifesavings in a pocket." A kid with a star of david on its chest says: "dad?" Dad replies: "It's not your fault." Two green boys converse. One says: "We should kill big tech." The other replies: "Like.. the CEO's? Levi, that's going to be really hard..." Underground person with a gopher says: "I don't even use html." A person is offering a boost to a girl in a short skirt and kneesocks. "Mental Outlaw is a fed." said a person to another one, who's wearing a parody of an openbsd t-shirt that says "OPEN BASED" They reply: "But the merch is nice." The first finishes: "No it is not." VOTE ANARCHY Well what do you think, asks a person wearing cat ears. SUPPORT TRANS RIGHTS. SAVE DEMOCRACY!
Seรกn Fobbe's avatar
Seรกn Fobbe

@seanfobbe@fediscience.org

๐Ÿ”” New Tutorial ๐Ÿ””

"How to Get Started with Legal Data Science"

โœ… Basic Introduction
โœ… Why Legal Data Science (esp. if you're a lawyer!)
โœ… Comprehensive List of resources for beginners
โœ… Focus on

Tutorial: seanfobbe.com/how-to-get-start

@rstats @politicalscience @law @dh

Screenshot of the first page of the linked tutorial.
ALT text detailsScreenshot of the first page of the linked tutorial.
mr_daemon's avatar
mr_daemon

@mr_daemon@untrusted.website

Some time ago, I was asked to share notes and details of how I archived my old CDROM media and I did reply in the thread, but never really posted the guide I wrote otherwise.

So here is the crappiest guide to dumping CD/DVD images on Linux/Unix-likes, in case it happens to be of use to anyone.

gist.github.com/mrdaemon/925f1

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@fosstodon.org

I'm writing a new . If a tutorial on creating an server needs to deal with persistent data, where should they be stored? I want the tutorial to focus as much as possible on implementing the business logic and as little as possible on other things.

OptionVoters
Regular files0 (0%)
SQLite0 (0%)
MySQL or PostgreSQL0 (0%)
Redis0 (0%)
Fedify: ActivityPub server framework's avatar
Fedify: ActivityPub server framework

@fedify@hollo.social

If you read a , what software would you like to see as an in the tutorial?

OptionVoters
Microblog (like Mastodon)21 (32%)
Long-form blog (like WordPress)10 (15%)
Photo blog (like Pixelfed)7 (11%)
Forum (like NodeBB)6 (9%)
Link aggregator (like Lemmy)9 (14%)
Much simpler one!13 (20%)
LabPlot's avatar
LabPlot

@LabPlot@floss.social

โžก๏ธ LabPlot - A free OriginPro alternative for Researchers (Multiple Peak Fit)

@labplot@lemmy.kde.social

Catalyst Nanomaterials Lab has published another video tutorial that will show you how to fit multiple peaks in your data using . Go check it out!

youtube.com/watch?v=Uf34So7DbZ

ๆดช ๆฐ‘ๆ†™ (Hong Minhee)'s avatar
ๆดช ๆฐ‘ๆ†™ (Hong Minhee)

@hongminhee@fosstodon.org

Now that I've created the `fedify init` command, I've been thinking about how to modify 's . If I make Fedify's tutorial use `fedify init`, it could omit the project setup part at the beginning. However, the chance to get some understanding of how things work like in the current tutorial would be lost. ๐Ÿค”

AlexTECPlayz's avatar
AlexTECPlayz

@alextecplayz@techhub.social

New video up on the channel:
UE5 MassAI Tutorial - NPCs, StateTrees and ZoneGraphs

youtube.com/watch?v=GN1HMtmNTv

Karsten Schmidt's avatar
Karsten Schmidt

@toxi@mastodon.thi.ng

: After 66 days of addressing 30 wildly varied use cases and building ~20 new example projects of varying complexity to illustrate how libraries can be used & combined, I'm taking a break to concentrate on other important thi.ngs...

With this overall selection I tried shining a light on common architectural patterns, but also some underexposed, yet interesting niche topics. Since there were many different techniques involved, it's natural not everything resonated with everyone. That's fine! Though, my hope always is that readers take an interest in a wide range of topics, and so many of these new examples were purposefully multi-faceted and hopefully provided insights for at least some parts, plus (in)directly communicated a core essence of the larger project:

Only individual packages (or small clusters) are designed & optimized for a set of particular use cases. At large, though, thi.ng explicitly does NOT offer any such guidance or even opinion. All I can offer are possibilities, nudges and cross-references, how these constructs & techniques can be (and have been) useful and/or the theory underpinning them. For some topics, thi.ng libs provide multiple approaches to achieve certain goals. This again is by design (not lack of it!) and stems from hard-learned experience, showing that many (esp. larger) projects highly benefit from more nuanced (sometimes conflicting approaches) compared to popular defacto "catch-all" framework solutions. To avid users (incl. myself) this approach has become a somewhat unique offering and advantage, yet in itself seems to be the hardest and most confusing aspect of the entire project to communicate to newcomers.

So seeing this list of new projects together, to me really is a celebration (and confirmation/testament) of the overall approach (which I've been building on since ~2006): From the wide spectrum/flexibility of use cases, the expressiveness, concision, the data-first approach, the undogmatic mix of complementary paradigms, the separation of concerns, no hidden magic state, only minimal build tooling requirements (a bundler is optional, but recommended for tree shaking, no more) โ€” these are all aspects I think are key to building better (incl. more maintainable & reason-able) software. IMO they are worth embracing & exposing more people to and this is what I've partially attempted to do with this series of posts...

ICYMI here's a summary of the 10 most recent posts (full list in the thi.ng/umbrella readme). Many of those examples have more comments than code...

021: Iterative animated polygon subdivision & heat map viz
mastodon.thi.ng/@toxi/11122194

022: Quasi-random voronoi lattice generator
mastodon.thi.ng/@toxi/11124441

023: Tag-based Jaccard similarity ranking using bitfields
mastodon.thi.ng/@toxi/11125696

024: 2.5D hidden line visualization of DEM files
mastodon.thi.ng/@toxi/11126950

025: Transforming & plotting 10k data points using SIMD
mastodon.thi.ng/@toxi/11128326

026: Shader meta-programming to generate 16 animated function plots
mastodon.thi.ng/@toxi/11129584

027: Flocking sim w/ neighborhood queries to visualize proximity
mastodon.thi.ng/@toxi/11130843

028: Randomized, space-filling, nested 2D grid layout generator
mastodon.thi.ng/@toxi/11132456

029: Forth-like DSL & livecoding playground for 2D geometry
mastodon.thi.ng/@toxi/11133502

030: Procedural text generation via custom DSL & parse grammar
mastodon.thi.ng/@toxi/11134707

Thomas Schรคfer's avatar
Thomas Schรคfer

@tschaefer@ipv6.social




ripe86.ripe.net/archives/video

ripe86.ripe.net/presentations/

Thomas Schรคfer's avatar
Thomas Schรคfer

@tschaefer@ipv6.social

Great work by @Oskar456






ripe87.ripe.net/programme/meet

ripe87.ripe.net/wp-content/upl

David Revoy's avatar
David Revoy

@davidrevoy@framapiaf.org

I just published my 1h22min video
"Tutorial: an Illustration from A to Z with Krita"
It's a real full lenght course suited to beginners, but also advanced digital painters. It starts from scratch with default brushes, pref' and breakdown all the process:

โ–ถ Peertube: check @shichimi (soon, still transcoding)
โ–ถ Youtube: youtu.be/uYdEkOyFUn8

The video thumbnail, showing an artwork of Kiki with my style and the title on the right.
ALT text detailsThe video thumbnail, showing an artwork of Kiki with my style and the title on the right.
ghostdancer's avatar
ghostdancer

@ghostdancer@mastodon.sdf.org

How to exit github.com/hakluke/how-to-exit

nish's avatar
nish

@nish@mastodon.gamedev.place

I wrote a post/tutorial on how to create responsive and expressive movement in Godot: nishchalb.github.io/posts/godo

Kyrylys's avatar
Kyrylys

@Kyrylys@frikiverse.zone

Tutorial rรกpido para bloquear y todas las instancias que han estado usando como prueba (de momento)
(En modo navegador de pc)
vas a codeberg.org//alexis/block-met le das a descargar, renombras el .txt como csv vas a preferencias > importar y exportar >ย ย importar > Pinchas en lista de seguidos y selecciona Lista de dominios bloqueados, carga el csv y listo!

Bamboy's avatar
Bamboy

@bamboy@mastodon.gamedev.place

1/15
on how I made a style procedural level generator, with visual examples and names of image processing algorithms being used for each step.

While I am using , the level generator code is standalone and I hope to write this in a way that is useful for any language. My focus is going to be on the actual steps being taken, and less on the programming.

๐Ÿ“ก RightToPrivacy & Tech Tips's avatar
๐Ÿ“ก RightToPrivacy & Tech Tips

@RTP@fosstodon.org ยท Reply to ๐Ÿ“ก RightToPrivacy & Tech Tips's post

๐Ÿง„ Browser Button Tutorial For / Phones / Devices

Includes :librewolf: profile pre-setup + desktop shortcut + icon + starting script starts upon browser open & stops i2prouter on browser close

๐Ÿ“ Tutorial / ๐Ÿ“ธ Photos:

buymeacoffee.com/politictech/s (public)

๐Ÿ“ก RightToPrivacy & Tech Tips's avatar
๐Ÿ“ก RightToPrivacy & Tech Tips

@RTP@fosstodon.org ยท Reply to ๐Ÿ“ก RightToPrivacy & Tech Tips's post

๐Ÿ’กVIDEO: Make I2P Desktop Shortcut (all in one), Starts i2prouter & dedicated I2P Librewolf Browser Profile & Stops i2prouter on browser close

๐Ÿ“บ WATCH (public): buymeacoffee.com/politictech/c

nukosu's avatar
nukosu

@nukosu@pao.moe

How to protect your before posting photos to