# 나만의 연합우주 마이크로블로그 만들기 > [!TIP] > 이 튜토리얼은 다음 언어로도 제공됩니다: [English](https://fedify.dev/tutorial/microblog) (영어), [日本語](https://zenn.dev/hongminhee/books/4a38b6358a027b) (일본어). > [!NOTE] > > 만약 [연합우주][](fediverse)나 [ActivityPub] 같은 용어가 생소하다면, 관련 검색을 좀 더 하고 나서 이 튜토리얼을 따라할 것을 권합니다. 이 튜토리얼에서는 ActivityPub 서버 프레임워크인 [Fedify]를 이용하여 [Mastodon]이나 [Misskey] 같은 ActivityPub 프로토콜을 구현하는 [마이크로블로그][](microblog)를 만들어 보도록 하겠습니다. 이 튜토리얼은 Fedify의 기반 동작 원리를 파악하는 것보다는 Fedify의 활용법에 좀 더 집중하려고 합니다. Fedify는 ActivityPub이나 그 외 표준(총칭하여 「연합우주」라 불리는)을 이용하여 연합 서버 앱을 만들기 위한 TypeScript 라이브러리입니다. 연합 서버 앱을 만들 때의 복잡함이나 번거로운 보일러플레이트 코드를 없애고, 비즈니스 로직과 사용자 경험에 집중할 수 있도록 하는 것이 Fedify의 목표입니다. Fedify 프로젝트에 관심이 생기셨다면, 아래의 자료를 참고해 주세요: - [웹사이트][Fedify] - [GitHub](https://github.com/dahlia/fedify) - [API 레퍼런스](https://jsr.io/@fedify/fedify) - [예제](https://github.com/dahlia/fedify/tree/main/examples) Fedify나 본 튜토리얼에 대한 질문이나 제안, 피드백 등은 [GitHub Discussions][](영어)에 올려 주시거나 연합우주 [@fedify@hollo.social][](영어 및 한국어)로 멘션 주시기 바랍니다. 아니면 [한국 연합우주 개발자 모임]의 [Discord 서버]에 들어오셔서 #fedify 채널(한국어)에서 말씀하셔도 됩니다. [연합우주]: https://ko.wikipedia.org/wiki/%ED%8E%98%EB%94%94%EB%B2%84%EC%8A%A4 [ActivityPub]: https://activitypub.rocks/ [Fedify]: https://fedify.dev/ [Mastodon]: https://joinmastodon.org/ko [Misskey]: https://misskey-hub.net/ko/ [마이크로블로그]: https://ko.wikipedia.org/wiki/%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C%EB%B8%94%EB%A1%9C%EA%B7%B8 [GitHub Discussions]: https://github.com/dahlia/fedify/discussions [@fedify@hollo.social]: https://hollo.social/@fedify [한국 연합우주 개발자 모임]: https://fedidev.kr/ [Discord 서버]: https://discord.gg/B2ABMBpHNA ## 대상 독자 이 튜토리얼은 Fedify를 배워서 ActivityPub 서버 소프트웨어를 만들어 보고 싶은 분들을 대상으로 합니다. 여러분이 HTML이나 HTTP를 이용하여 웹앱을 제작해 본 경험이 있으며, 명령행 인터페이스나 SQL, JSON, 기본적인 JavaScript 등을 이해한다고 가정합니다. 하지만 TypeScript나 JSX, ActivityPub, Fedify 등은 이 튜토리얼에서 필요한 만큼 가르쳐 드릴 것이니 몰라도 괜찮습니다. ActivityPub 소프트웨어를 만들어 본 경험은 필요 없지만, 그래도 Mastodon이나 Misskey 같은 ActivityPub 소프트웨어를 하나 정도는 써봤다고 가정합니다. 그래야 우리가 무엇을 만드려고 하는지 감이 잡히기 때문입니다. *[JSX]: JavaScript XML ## 목표 이 튜토리얼에서는 Fedify를 이용해 ActivityPub으로 다른 연합형 소프트웨어 및 서비스와 소통 가능한 일인용 마이크로블로그를 만듭니다. 이 소프트웨어는 다음과 같은 기능을 포함합니다. - 사용자는 단 하나의 계정을 만들 수 있습니다. - 연합우주 내 다른 계정이 사용자를 팔로 할 수 있습니다. - 팔로워는 사용자를 팔로하다가 그만 둘 수 있습니다. - 사용자는 자신의 팔로워 목록을 볼 수 있습니다. - 사용자는 게시물을 올릴 수 있습니다. - 사용자의 게시물은 연합우주 내 팔로워들에게 보입니다. - 사용자는 연합우주 내 다른 계정을 팔로 할 수 있습니다. - 사용자는 자신이 팔로하는 계정 목록을 볼 수 있습니다. - 사용자는 자신이 팔로하는 계정이 작성한 게시물들을 시간순 목록으로 볼 수 있습니다. 튜토리얼을 단순화하기 위해 다음과 같은 기능 제약을 둡니다. - 계정 프로필(소개문, 사진 등)은 설정할 수 없습니다. - 한 번 만든 계정은 삭제 할 수 없습니다. - 한 번 올린 게시물은 고치거나 지울 수 없습니다. - 한 번 팔로한 다른 계정은 팔로잉을 그만 둘 수 없습니다. - 좋아요, 공유, 댓글은 없습니다. - 검색 기능은 없습니다. - 인증 및 권한 검사 등의 보안 기능은 없습니다. 물론, 튜토리얼을 끝까지 진행한 뒤 기능을 덧붙이는 것은 얼마든지 하셔도 좋습니다. 좋은 연습이 될 것입니다. 완성된 소스 코드는 [GitHub 저장소]에 올라와 있으며, 각 구현 단계에 따라 커밋이 나뉘어져 있으니 참고 바랍니다. [GitHub 저장소]: https://github.com/dahlia/microblog ## 개발 환경 셋업 ### Node.js 설치하기 Fedify는 [Deno], [Bun], [Node.js], 이 세 가지 JavaScript 런타임을 지원합니다. 그 중에서 Node.js가 가장 널리 쓰이므로, 이 튜토리얼에서는 Node.js를 기준으로 설명하도록 하겠습니다. > [!TIP] > > JavaScript 런타임이란 JavaScript 코드를 실행하는 플랫폼을 뜻합니다. 웹브라우저도 JavaScript 런타임의 하나이며, 명령줄이나 서버에서는 Node.js 등이 널리 쓰입니다. 최근에는 [Cloudflare Workers] 같은 클라우드 에지 함수들도 JavaScript 런타임의 하나로 각광 받고 있습니다. Fedify를 사용하기 위해서는 Node.js 20.0.0 이상의 버전이 필요합니다. [여러 설치법]이 있으니 자신에가 가장 알맞는 방법으로 Node.js를 설치하시기 바랍니다. Node.js가 설치되면 `node` 명령어와 `npm` 명령어가 생깁니다: ~~~~ sh node --version npm --version ~~~~ [Deno]: https://deno.com/ [Bun]: https://bun.sh/ [Node.js]: https://nodejs.org/ [Cloudflare Workers]: https://workers.cloudflare.com/ [여러 설치법]: https://nodejs.org/en/download/package-manager ### `fedify` 명령어 설치 Fedify 프로젝트를 셋업하기 위해 `fedify` 명령어를 시스템에 설치해야 합니다. [여러 설치 방법][Fedify CLI 설치법]이 있지만, `npm` 명령으로 까는 것이 가장 간편합니다: ~~~~ sh npm install -g @fedify/cli ~~~~ 설치가 되었다면, `fedify` 명령어를 쓸 수 있는지 확인해 봅시다. 아래 명령으로 `fedify` 명령어의 버전을 알 수 있습니다. ~~~~ sh fedify --version ~~~~ 결과로 나온 버전 번호가 1.0.0 이상인지 확인하십시오. 그보다 옛날 버전이면 이 튜토리얼을 제대로 따라할 수 없습니다. [Fedify CLI 설치법]: https://fedify.dev/cli#installation ### `fedify init`으로 프로젝트 초기화 새 Fedify 프로젝트를 시작하기 위해, 작업할 디렉터리 경로를 정합시다. 이 튜토리얼에서는 *microblog*라고 명명하겠습니다. `fedify init` 명령 뒤에 디렉터리 경로를 적고 실행합니다 (디렉터리가 아직 존재하지 않아도 괜찮습니다): ~~~~ sh fedify init microblog ~~~~ `fedify init` 명령을 실행하면 아래와 같이 몇 가지 질문 프롬프트가 나옵니다. 차례대로 *Node.js*, *npm*, *Hono*, *In-memory*, *In-process*를 선택합니다: ~~~~ console ___ _____ _ _ __ /'_') | ___|__ __| (_)/ _|_ _ .-^^^-/ / | |_ / _ \/ _` | | |_| | | | __/ / | _| __/ (_| | | _| |_| | <__.|_|-|_| |_| \___|\__,_|_|_| \__, | |___/ ? 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 ~~~~ > [!NOTE] > 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* 파일들이 있습니다. 생성된 소스 코드는 동작하는 데모입니다. 우선은 이 상태로 잘 돌아가는지 확인합시다: ~~~~ sh npm run dev ~~~~ 위 명령을 실행하면 <kbd>Ctrl</kbd>+<kbd>C</kbd> 키를 누르기 전까지는 서버가 실행된 채로 있습니다: ~~~~ console Server started at http://0.0.0.0:8000 ~~~~ 서버가 실행된 상태에서, 새 터미널 탭을 열고 아래 명령을 실행합니다: ~~~~ sh fedify lookup http://localhost:8000/users/john ~~~~ 위 명령은 우리가 로컬에 띄운 ActivityPub 서버의 한 액터(actor)를 조회한 것입니다. ActivityPub에서 액터는 여러 ActivityPub 서버들 사이에서 접근 가능한 계정이라고 보시면 됩니다. 아래와 같은 결과가 출력되면 정상입니다: ~~~~ console ✔ 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*이라는 것을 알 수 있습니다. > [!TIP] > > `fedify lookup`은 ActivityPub 객체를 조회하는 명령어입니다. 이는 Mastodon에서 해당 URI로 검색하는 것과 같은 동작을 합니다. (물론, 현재 여러분의 서버는 로컬에서만 접근 가능하기 때문에 아직 Mastodon에서 검색해도 결과가 나오지는 않을 것입니다.) > > 여러분이 `fedify lookup` 명령어보다 `curl`을 더 선호하신다면, 아래 명령으로도 액터 조회가 가능합니다 (`-H` 옵션으로 `Accept` 헤더를 함께 보내는 것에 주의하십시오): > > ~~~~ sh > curl -H"Accept: application/activity+json" http://localhost:8000/users/john > ~~~~ > > 단, 위와 같이 조회할 경우 그 결과는 맨눈으로 확인하기 어려운 JSON 형식이 될 것입니다. 만약 시스템에 `jq` 명령어도 함께 깔려있다면, `curl`과 `jq`를 함께 쓸 수도 있습니다: > > ~~~~ sh > 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 설정이 갖춰져 있기 때문에 포매터나 린트 등과 씨름할 필요도 없습니다. > [!WARNING] > Visual Studio와 헷갈리시면 안 됩니다. Visual Studio Code와 Visual Studio는 브랜드만 공유할 뿐 서로 완전히 다른 소프트웨어입니다. [Visual Studio Code를 설치]하신 다음, *파일* → *폴더 열기…* 메뉴를 눌러 작업 디렉터리를 불러오십시오. 만약 우하단에 「이 리포지토리에 대한 권장되는 biomejs의 ‘Biome’ 확장을(를) 설치하시겠습니까?」라고 묻는 창이 뜨면 *설치* 버튼을 눌러 해당 확장을 설치하세요. 이 확장을 설치하면 TypeScript 코드를 작성할 때 들여쓰기나 띄어쓰기 같은 코드 스타일과 씨름할 필요 없이 자동으로 코드가 서식화 됩니다. > [!TIP] > > 여러분이 충성스러운 Emacs 또는 Vim 사용자라면, 쓰던 여러분의 최애 에디터를 쓰는 것을 말리지 않겠습니다. 다만, TypeScript LSP 설정은 짚고 넘어갈 것을 권합니다. TypeScript LSP 설정 여부에 따라 생산성의 차이가 크기 때문입니다. [Visual Studio Code]: https://code.visualstudio.com/ [Visual Studio Code를 설치]: https://code.visualstudio.com/docs/setup/setup-overview *[LSP]: Language Server Protocol ## 선수 지식 ### TypeScript 코드를 수정하기 전에, 간단히 TypeScript에 대해 짚고 넘어가도록 하겠습니다. 만약 여러분이 이미 TypeScript에 익숙하다면 이 장은 넘기셔도 좋습니다. TypeScript는 JavaScript에 정적 타입 검사를 추가한 것입니다. TypeScript 문법은 JavaScript 문법과 거의 같지만, 변수나 함수 문법에 타입을 지정할 수 있다는 것이 큰 차이입니다. 타입 지정은 변수나 매개변수 뒤에 콜론(`:`)을 붙여서 나타냅니다. 예를 들어, 다음 코드는 `foo` 변수가 문자열(`string`)이라는 것을 나타냅니다: ~~~~ typescript let foo: string; ~~~~ 만약 위와 같이 선언된 `foo` 변수에 문자열이 아닌 다른 타입의 값을 대입하려고 하면 Visual Studio Code가 *실행해보기 전에* 미리 빨간 밑줄을 그어주며 타입 오류를 보여줄 것입니다: ~~~~ typescript foo = 123; // ts(2322): 'number' 형식은 'string' 형식에 할당할 수 없습니다. ~~~~ 코딩하면서 빨간 밑줄을 만나면 지나치지 않도록 하십시오. 무시하고 프로그램을 실행하면 그 부분에서 실제로 오류가 날 가능성이 높습니다. TypeScript로 코딩을 하며 마주치는 가장 흔한 타입 오류의 유형은 바로 `null` 가능성 오류입니다. 예를 들어, 다음 코드를 보면 `bar` 변수는 문자열(`string`)일 수도 있지만 `null`일 수도 있다(`string | null`)고 되어 있습니다: ~~~~ typescript const bar: string | null = someFunction(); ~~~~ 만약 이 변수의 내용에서 가장 첫 글자를 꺼내려고 다음과 같이 코드를 쓴다면 어떻게 될까요? ~~~~ typescript const firstChar = bar.charAr(0); // ts(18047): 'bar'은(는) 'null'일 수 있습니다. ~~~~ 위와 같이 타입 오류가 나게 됩니다. `bar`가 어쩔 때는 `null`일 수 있는데, 그 경우에 `null.charAt(0)`을 호출하면 오류가 날 수 있으니 코드를 고치라는 이야기입니다. 그런 경우에 아래와 같이 `null`인 경우의 처리를 추가해 줘야 합니다. ~~~~ typescript const firstChar = bar === null ? "" : bar.charAr(0); ~~~~ 이와 같이 TypeScript는 코딩할 때 미처 생각하지 못했던 경우의 수를 떠올리게 해서 버그를 미연에 방지하도록 도와줍니다. 또, TypeScript의 부수적인 장점 중 하나는 자동 완성이 된다는 것입니다. 예를 들어, `foo.`까지 입력하면 문자열 객체가 가진 메서드 목록이 나와서 그 중에서 고를 수 있습니다. 이를 통해 일일히 문서를 확인하지 않고서도 빠르게 코딩이 가능합니다. 이 튜토리얼을 따라하면서 TypeScript의 매력도 함께 느끼시기 바랍니다. 무엇보다 Fedify는 TypeScript와 함께 쓸 때 가장 경험이 좋으니까요. > [!TIP] > > TypeScript를 제대로 찬찬히 배워보고 싶으시다면, 공식 [TypeScript 핸드북]을 읽으실 것을 추천합니다. 전부 읽는데 약 30분 정도 소요됩니다. [TypeScript 핸드북]: https://www.typescriptlang.org/ko/docs/handbook/intro.html ### JSX JSX는 JavaScript 코드 안에 XML 또는 HTML을 집어넣을 수 있도록 한 JavaScript의 문법 확장입니다. TypeScript에서도 쓸 수 있으며, 이 경우에는 TSX라고 부르기도 합니다. 이 튜토리얼에서는 모든 HTML을 JSX 문법을 통해 JavaScript 코드 안에 작성할 것입니다. JSX에 이미 익숙한 분들은 이 장을 넘기셔도 됩니다. 예를 들어, 아래 코드는 `<div>` 엘리먼트가 최상위에 있는 HTML 트리를 `html` 변수에 대입합니다: ~~~~ tsx const html = <div> <p id="greet">안녕, <strong>JSX</strong>!</p> </div>; ~~~~ 중괄호를 통해 JavaScript 표현식을 넣는 것도 가능합니다 (아래 코드는 물론 `getName()` 함수가 있다고 가정합니다): ~~~~ tsx const html = <div title={"안녕, " + getName() + "!"}> <p id="greet">안녕, <strong>{getName()}</strong>!</p> </div>; ~~~~ JSX의 특징 중 하나는 컴포넌트(component)라고 불리는 자신만의 태그를 정의할 수 있다는 것입니다. 컴포넌트는 평범한 JavaScript 함수로 정의할 수 있습니다. 예를 들어, 아래 코드는 `<Container>` 컴포넌트를 정의하고 사용하는 것을 보여줍니다 (컴포넌트 이름은 일반적으로 PascalCase 스타일을 따릅니다): ~~~~ tsx 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` 인터페이스를 선언하고 사용했습니다. > [!TIP] > 저네릭 타입의 타입 인자는 여러 개가 될 수 있으며, 쉼표로 각 인자를 구분합니다. 예를 들어, `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 트리가 대입되게 됩니다. > [!TIP] > > JSX는 React 프로젝트에서 발명되어 널리 쓰이기 시작했습니다. JSX에 대해 자세히 알고 싶으시다면, React 문서의 [JSX로 마크업 작성하기] 및 [중괄호가 있는 JSX 안에서 JavaScript 사용하기] 섹션을 읽어 보세요. [Hono]: https://hono.dev/ [저네릭 타입]: https://www.typescriptlang.org/ko/docs/handbook/2/generics.html [JSX로 마크업 작성하기]: https://ko.react.dev/learn/writing-markup-with-jsx [중괄호가 있는 JSX 안에서 JavaScript 사용하기]: https://ko.react.dev/learn/javascript-in-jsx-with-curly-braces *[TSX]: TypeScript XML ## 계정 생성 페이지 자, 이제 본격적인 개발에 돌입합시다. 가장 먼저 만들 것은 바로 계정 생성 페이지입니다. 계정을 만들어야 게시물도 올리고 다른 계정을 팔로 할 수도 있겠죠. 보이는 것부터 만들어 봅시다. 먼저 *src/views.tsx* 파일을 만듭니다. 그리고 그 파일 안에 JSX로 `<Layout>` 컴포넌트를 정의합니다: ~~~~ tsx 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 프레임워크를 사용하기로 합니다. > [!TIP] > > 변수나 매개변수의 타입을 TypeScript의 타입 검사기가 추론할 수 있는 경우, 위의 `props` 같이 타입 표기를 생략해도 무방합니다. 이렇게 타입 표기가 생략된 경우에도, Visual Studio Code에서 변수 이름 위에 마우스 커서를 가져다 대면 해당 변수가 어떤 타입인지 확인할 수 있습니다. 그 다음, 같은 파일에서 레이아웃 안에 들어갈 `<SetupForm>` 컴포넌트를 정의합니다: ~~~~ tsx 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`합니다: ~~~~ typescript import { Layout, SetupForm } from "./views.tsx"; ~~~~ 그리고 나서 */setup* 페이지에서 앞서 만든 계정 생성 양식을 표시합니다: ~~~~ tsx app.get("/setup", (c) => c.html( <Layout> <SetupForm /> </Layout>, ), ); ~~~~ 자, 그럼 웹 브라우저에서 <http://localhost:8000/setup> 페이지를 열어 봅시다. 아래와 같은 화면이 보여야 정상입니다: ![계정 생성 페이지](https://hackmd.io/_uploads/BJWb-oDjR.png) > [!NOTE] > JSX를 사용하기 위해서는 소스 파일의 확장자가 *.jsx* 또는 *.tsx*여야 합니다. 이 장에서 편집한 두 파일 모두 확장자가 *.tsx*라는 사실에 주의하세요. [Pico CSS]: https://picocss.com/ ### 데이터베이스 셋업 자, 보이는 부분을 구현했으니, 이제 동작을 구현해야 할 차례입니다. 계정 정보를 저장할 곳이 필요한데, [SQLite]를 쓰도록 합시다. SQLite는 작은 규모의 애플리케이션에 알맞는 관계형 데이터베이스입니다. 우선 계정 정보를 담을 테이블을 선언합시다. 앞으로 모든 테이블 선언은 *src/schema.sql* 파일에 작성하도록 하겠습니다. 계정 정보는 `users` 테이블에 담습니다: ~~~~ sqlite 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* 파일을 받아서 압축을 해제하면 됩니다. 패키지 관리자를 사용하면 다음 명령으로 설치할 수도 있습니다: ~~~~ sh 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` 명령어가 준비되었다면 이제 이를 이용해서 데이터베이스 파일을 생성합시다: ~~~~ sh sqlite3 microblog.sqlite3 < src/schema.sql ~~~~ 위 명령을 실행하면 *microblog.sqlite3* 파일이 생기는데, 이 안에 SQLite 데이터가 저장됩니다. [SQLite]: https://www.sqlite.org/ [SQLite 웹사이트에서 받거나]: https://www.sqlite.org/download.html ### 앱에서 데이터베이스 연결 이제 저희가 만드는 앱에서 SQLite 데이터베이스를 사용할 일만 남았습니다. Node.js에서 SQLite 데이터베이스를 사용하기 위해서는 SQLite 드라이버 라이브러리가 필요한데요, 저희는 *[better-sqlite3]* 패키지를 쓰도록 하겠습니다. 패키지는 `npm` 명령으로 간단하게 깔 수 있습니다: ~~~~ sh npm add better-sqlite3 npm add --save-dev @types/better-sqlite3 ~~~~ > [!TIP] > *[@types/better-sqlite3]* 패키지는 TypeScript를 위해 *better-sqlite* 패키지의 API에 대한 타입 정보를 담고 있습니다. 이 패키지를 설치해야 Visual Studio Code에서 편집할 때 자동 완성이나 타입 검사가 가능합니다. > > 이와 같이 *@types/* 범위 안에 있는 패키지를 [Definitely Typed] 패키지라고 합니다. 어떤 라이브러리가 TypeScript로 작성되지 않았을 때, 커뮤니티에서 타입 정보를 추가 기입하여 패키지로 만든 것입니다. 패키지를 설치했으니, 이 패키지를 이용하여 데이터베이스에 연결하는 코드를 짭시다. *src/db.ts*라는 새 파일을 만들어서 아래와 같이 코딩합니다: ~~~~ typescript import Database from "better-sqlite3"; const db = new Database("microblog.sqlite3"); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); export default db; ~~~~ > [!TIP] > 참고로 `db.pragma()` 함수를 통해 한 설정은 다음과 같은 효과를 지닙니다: > > [`journal_mode = WAL`] > : SQLite에서 원자적 커밋 및 롤백을 구현하는 방식으로 [로그 선행 기입] 모드를 채택합니다. 이 모드는 기본값인 [롤백 저널] 모드에 비해 대부분의 경우에서 더 성능이 뛰어납니다. > > [`foreign_keys = ON`] > : SQLite에서는 기본적으로 외래 키 제약 조건을 검사하지 않습니다. 이 설정을 켜면 외래 키 제약 조건을 검사하게 되어 데이터 무결성을 지키는 데에 도움이 됩니다. 그리고 `users` 테이블에 저장되는 레코드를 JavaScript에서 표현하는 타입을 선언합시다. *src/schema.ts* 파일을 만들고 아래와 같이 `User` 타입을 정의합니다: ~~~~ typescript export interface User { id: number; username: string; } ~~~~ [better-sqlite3]: https://github.com/WiseLibs/better-sqlite3 [@types/better-sqlite3]: https://www.npmjs.com/package/@types/better-sqlite3 [Definitely Typed]: https://github.com/DefinitelyTyped/DefinitelyTyped [`journal_mode = WAL`]: https://www.sqlite.org/draft/wal.html [로그 선행 기입]: https://ko.wikipedia.org/wiki/%EB%A1%9C%EA%B7%B8_%EC%84%A0%ED%96%89_%EA%B8%B0%EC%9E%85 [롤백 저널]: https://www.sqlite.org/draft/lockingv3.html#rollback [`foreign_keys = ON`]: https://www.sqlite.org/foreignkeys.html#fk_enable ### 레코드 삽입 데이터베이스에 연결했으니, 레코드를 삽입할 차례입니다. *src/app.tsx* 파일을 열어 레코드 삽입에 쓰일 `db` 객체와 `User` 타입을 `import`합니다: ~~~~ typescript import db from "./db.ts"; import type { User } from "./schema.ts"; ~~~~ `POST /setup` 핸들러를 구현합니다: ~~~~ typescript 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` 핸들러에도 계정이 이미 있는지 검사하는 코드를 추가합니다: ~~~~ typescript 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 데이터베이스에 레코드가 잘 삽입되었나 확인도 해 봅니다: ~~~~ sh echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3 ~~~~ 레코드가 잘 삽입되었다면 아래와 같이 출력될 것입니다 (물론, `johndoe`는 여러분이 입력한 아이디에 따라 달라지겠죠): | `id` | `username` | |------|------------| | `1` | `johndoe` | ## 프로필 페이지 이제 계정이 생성되었으니 계정 정보를 보여주는 프로필 페이지를 구현합시다. 비록 보여 줄 정보가 거의 없지만요. 이번에도 보이는 것부터 작업하도록 하겠습니다. *src/views.tsx* 파일에 `<Profile>` 컴포넌트를 정의합니다: ~~~~ tsx 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`합니다: ~~~~ typescript import { Layout, Profile, SetupForm } from "./views.tsx"; ~~~~ 그리고 `<Profile>` 컴포넌트를 표시하는 `GET /users/{username}` 핸들러를 추가합니다: ~~~~ tsx 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을 바꿔야 합니다) 페이지를 열어 보세요. 아래와 같은 화면이 나와야 합니다: ![프로필 페이지](https://hackmd.io/_uploads/r1_1eDOjA.png) > [!TIP] > > 연합우주 핸들(fediverse handle), 줄여서 핸들이란 연합우주 내에서 계정을 가리키는 고유한 주소 같은 것입니다. 예를 들면 `@hongminhee@fosstodon.org`처럼 생겼습니다. 이메일 주소와 비슷하게 생겼는데, 실제 구성도 이메일 주소와 비슷합니다. 맨 처음에 `@`이 오고, 그 다음에 이름, 그리고 다시 `@`이 온 뒤, 마지막에 계정이 속한 서버의 도메인 이름이 옵니다. 때때로 맨 앞의 `@`이 생략되기도 합니다. > > 기술적으로는 핸들은 [WebFinger]와 [`acct:` URI 형식]이라는 두 개의 표준으로 구현됩니다. Fedify가 이를 구현하고 있기 때문에, 이 튜토리얼을 진행하는 동안 여러분은 구현 세부 사항을 알지 않아도 괜찮습니다. [WebFinger]: https://datatracker.ietf.org/doc/html/rfc7033 [`acct:` URI 형식]: https://datatracker.ietf.org/doc/html/rfc7565 ## 액터 구현하기 ActivityPub은 그 이름에서도 드러나듯, 액티비티(activity)를 주고 받는 프로토콜입니다. 글쓰기, 글 고치기, 글 지우기, 글에 좋아요 찍기, 댓글 달기, 프로필 고치기… 소셜 미디어에서 일어나는 모든 일들을 액티비티로 표현합니다. 그리고 모든 액티비티는 액터(actor)에서 액터로 전송됩니다. 예를 들어, 홍길동이 글을 쓰면 「글쓰기」(`Create(Note)`) 액티비티가 홍길동으로부터 홍길동의 팔로워들에게 전송됩니다. 그 글에 임꺽정이 좋아요를 찍으면 「좋아요」(`Like`) 액티비티가 임꺽정으로부터 홍길동에게 전송됩니다. 따라서 ActivityPub을 구현하는 가장 첫걸음은 액터를 구현하는 것입니다. `fedify init` 명령으로 생성된 데모 앱에 이미 아주 간단한 액터가 구현되어 있긴 하지만, Mastodon이나 Misskey 같은 실제의 소프트웨어들과 소통하기 위해서는 액터를 좀 더 제대로 구현할 필요가 있습니다. 일단, 현재의 구현을 한 번 살펴볼까요? *src/federation.ts* 파일을 열어봅시다: ~~~~ typescript 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을 덧붙이세요: ~~~~ sqlite 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* 데이터베이스 파일에 적용합시다: ~~~~ sh sqlite3 microblog.sqlite3 < src/schema.sql ~~~~ > [!TIP] > 앞서 `users` 테이블을 정의할 때 `CREATE TABLE IF NOT EXISTS` 문을 사용했기 때문에, 여러 번 실행해도 괜찮습니다. 그리고 `actors` 테이블에 저장되는 레코드를 JavaScript로 표현할 타입도 *src/schema.ts*에 정의합니다: ~~~~ typescript 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` 칼럼에 들어갈 이름도 입력 받도록 합시다: ~~~~ tsx 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`합니다: ~~~~ typescript import type { Actor, User } from "./schema.ts"; ~~~~ 이제 입력 받은 이름을 비롯해 필요한 정보들을 `actors` 테이블의 레코드로 만드는 코드를 `POST /setup` 핸들러에 추가합니다: ~~~~ typescript 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}` 핸들러에도 적용합니다: ~~~~ tsx 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>, ); }); ~~~~ ~~~~ tsx 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>, ); }); ~~~~ > [!TIP] > TypeScript에서 `A & B`는 `A` 타입인 동시에 `B` 타입인 객체를 뜻합니다. 예를 들어, `{ a: number } & { b: string }` 타입이 있다고 할 때, `{ a: 123 }`이나 `{ b: "foo" }`는 해당 타입을 만족하지 못하지만, `{ a: 123, b: "foo" }`는 해당 타입을 만족합니다. 마지막으로, *src/federation.ts* 파일을 열어, 액터 디스패처 아래에 다음 코드를 추가합니다: ~~~~ typescript federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); ~~~~ `setInboxListeners()` 메서드는 지금으로서는 신경 쓰지 마세요. 이 역시 수신함에 대해 설명할 때 함께 다루도록 하겠습니다. 다만, 계정 생성 코드에서 사용한 `getInboxUri()` 메서드가 제대로 동작하려면 위 코드가 필요하다는 점만 짚고 넘어가겠습니다. 코드를 모두 고쳤다면, 브라우저에서 <http://localhost:8000/setup> 페이지를 열어서 다시 계정을 생성합니다: ![계정 생성 페이지](https://hackmd.io/_uploads/ryV2sGCs0.png) ### 액터 디스패처 `actors` 테이블을 만들고 레코드도 채웠으니, 다시 *src/federation.ts* 파일을 고쳐봅시다. 먼저 `db` 객체와 `Endpoints` 및 `Actor`를 `import`합니다: ~~~~ typescript import { Endpoints, Person, createFederation } from "@fedify/fedify"; import db from "./db.ts"; import type { Actor, User } from "./schema.ts"; ~~~~ 필요한 것들을 `import`했으니 `setActorDispatcher()` 메서드를 고쳐봅시다: ~~~~ typescript 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을 일치시키도록 하겠습니다. > [!TIP] > 눈썰미가 좋은 분들은 눈치채셨겠지만, Hono와 Fedify 양쪽에서 `GET /users/{identifier}`에 대한 핸들러를 겹쳐서 정의하고 있습니다. 그럼 해당 요청을 실제로 보내면 어느 쪽에서 응답하게 될까요? 정답은 요청의 `Accept` 헤더에 따라 달라진다는 것입니다. `Accept: text/html` 헤더와 함께 요청을 보내면 Hono 쪽 요청 핸들러에서 응답합니다. `Accept: application/activity+json` 헤더와 함께 요청을 보내면 Fedify 쪽 요청 핸들러에서 응답합니다. > > 이렇게 요청의 `Accept` 헤더에 따라 다른 응답을 주는 방식을 HTTP [내용 협상][](content negotiation)이라고 하며, Fedify 자체에서 내용 협상을 구현합니다. 좀 더 구체적으로는, 모든 요청은 Fedify를 한 번 거치게 되며, ActivityPub과 관련된 요청이 아닐 경우 연동된 프레임워크, 이 튜토리얼에서는 Hono에게 요청을 건내주게 되어 있습니다. > [!TIP] > > Fedify에서는 모든 URI 및 URL을 [`URL`] 인스턴스로 표현합니다. [내용 협상]: https://developer.mozilla.org/ko/docs/Web/HTTP/Content_negotiation [`URL`]: https://developer.mozilla.org/ko/docs/Web/API/URL ### 테스트 그럼 한 번 액터 디스패처를 테스트해 볼까요? 서버가 켜진 상태에서, 새 터미널 탭을 열어 아래 명령을 입력합니다: ~~~~ sh fedify lookup http://localhost:8000/users/alice ~~~~ `alice`이라는 계정이 없기 때문에, 아까와는 다르게 이제 다음과 같이 오류가 날 것입니다: ~~~~ console ✔ Looking up the object... Failed to fetch the object. It may be a private object. Try with -a/--authorized-fetch. ~~~~ 그럼 `johndoe` 계정도 조회해 봅시다: ~~~~ sh fedify lookup http://localhost:8000/users/johndoe ~~~~ 이제는 결과가 잘 나옵니다: ~~~~ console ✔ 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가 알아서 해 주지만, 키 쌍을 생성하고 보존하는 것은 직접 구현하셔야 합니다. > [!WARNING] > > 개인 키(비밀 키)는 이름에서 드러나듯 서명할 주체 이외에는 접근할 수 없어야 합니다. 반면, 공개 키는 그 용도 자체가 공개하기 위함이므로 누구나 접근해도 괜찮습니다. [디지털 서명]: https://ko.wikipedia.org/wiki/%EB%94%94%EC%A7%80%ED%84%B8_%EC%84%9C%EB%AA%85 ### 테이블 생성 개인 키와 공개 키 쌍을 저장할 `keys` 테이블을 *src/schema.sql*에 정의합니다: ~~~~ sqlite 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)`에 걸려 있으니, 한 사용자에 대해 최대 두 쌍의 키가 있을 수 있습니다. > [!TIP] > 이 튜토리얼에서 자세히 설명할 수는 없지만, 2024년 9월 현재 ActivityPub 네트워크는 RSA-PKCS#1-v1.5 형식에서 Ed25519 형식으로 이행하고 있는 중이라고 알고 계시면 좋습니다. 어떤 소프트웨어는 RSA-PKCS#1-v1.5 형식만 받아들이고 어떤 소프트웨어는 Ed25519 형식을 받아들입니다. 따라서, 양쪽 모두와 소통하기 위해서는 두 쌍의 키가 모두 필요한 것입니다. `private_key` 및 `public_key` 칼럼은 문자열을 받을 수 있게 되어 있는데, 우리는 여기에 JSON 데이터를 넣을 예정입니다. 개인 키와 공개 키를 JSON으로 인코딩하는 방법에 대해서는 뒤에서 차차 다루게 될 것입니다. 그럼 `keys` 테이블을 생성합시다: ~~~~ sh sqlite3 microblog.sqlite3 < src/schema.sql ~~~~ `keys` 테이블에 저장되는 레코드를 JavaScript로 표현할 `Key` 타입도 *src/schema.ts* 파일에 정의합니다: ~~~~ typescript export interface Key { user_id: number; type: "RSASSA-PKCS1-v1_5" | "Ed25519"; private_key: string; public_key: string; created: string; } ~~~~ [RSA-PKCS#1-v1.5]: https://www.rfc-editor.org/rfc/rfc2313 [Ed25519]: https://ed25519.cr.yp.to/ ### 키 쌍 디스패처 이제 키 쌍을 생성하고 불러오는 코드를 짜야 합니다. *src/federation.ts* 파일을 열고 Fedify에서 제공되는 `exportJwk()`, `generateCryptoKeyPair()`, `importJwk()` 함수들과 앞서 정의한 `Key` 타입을 `import`합시다: ~~~~ typescript import { Endpoints, Person, createFederation, exportJwk, // 추가됨 generateCryptoKeyPair, // 추가됨 importJwk, // 추가됨 } from "@fedify/fedify"; import type { Actor, Key, User } from "./schema.ts"; ~~~~ 그리고 액터 디스패처 부분을 다음과 같이 고칩니다: ~~~~ typescript federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { const user = db .prepare<unknown[], User & Actor>( ` SELECT * FROM users JOIN actors ON (users.id = actors.user_id) WHERE users.username = ? `, ) .get(identifier); if (user == null) return null; const keys = await ctx.getActorKeyPairs(identifier); return new Person({ id: ctx.getActorUri(identifier), preferredUsername: identifier, name: user.name, inbox: ctx.getInboxUri(identifier), endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri(), }), url: ctx.getActorUri(identifier), publicKey: keys[0].cryptographicKey, assertionMethods: keys.map((k) => k.multikey), }); }) .setKeyPairsDispatcher(async (ctx, identifier) => { const user = db .prepare<unknown[], User>("SELECT * FROM users WHERE username = ?") .get(identifier); if (user == null) return []; const rows = db .prepare<unknown[], Key>("SELECT * FROM keys WHERE keys.user_id = ?") .all(user.id); const keys = Object.fromEntries( rows.map((row) => [row.type, row]), ) as Record<Key["type"], Key>; const pairs: CryptoKeyPair[] = []; // 사용자가 지원하는 두 키 형식 (RSASSA-PKCS1-v1_5 및 Ed25519) 각각에 대해 // 키 쌍을 보유하고 있는지 확인하고, 없으면 생성 후 데이터베이스에 저장: for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) { if (keys[keyType] == null) { logger.debug( "The user {identifier} does not have an {keyType} key; creating one...", { identifier, keyType }, ); const { privateKey, publicKey } = await generateCryptoKeyPair(keyType); db.prepare( ` INSERT INTO keys (user_id, type, private_key, public_key) VALUES (?, ?, ?, ?) `, ).run( user.id, keyType, JSON.stringify(await exportJwk(privateKey)), JSON.stringify(await exportJwk(publicKey)), ); pairs.push({ privateKey, publicKey }); } else { pairs.push({ privateKey: await importJwk( JSON.parse(keys[keyType].private_key), "private", ), publicKey: await importJwk( JSON.parse(keys[keyType].public_key), "public", ), }); } } return pairs; }); ~~~~ 우선 가장 먼저 주목해야 할 것은 `setActorDispatcher()` 메서드에 연달아 호출되고 있는 `setKeyPairsDispatcher()` 메서드입니다. 이 메서드는 콜백 함수에서 반환된 키 쌍들을 계정에 연결하는 역할을 합니다. 이렇게 키 쌍들을 연결해야 Fedify가 액티비티를 발신할 때 자동으로 등록된 개인 키들로 디지털 서명을 추가합니다. `generateCryptoKeyPair()` 함수는 새로운 개인 키 및 공개 키 쌍을 생성하여 `CryptoKeyPair` 객체로 반환합니다. 참고로 `CryptoKeyPair` 타입은 `{ privateKey: CryptoKey; publicKey: CryptoKey; }` 형식입니다. `exportJwk()` 함수는 `CryptoKey` 객체를 JWK 형식으로 표현한 객체를 반환합니다. JWK 형식이 무엇인지 알 필요는 없습니다. 그저 암호 키를 JSON으로 표현하는 표준적인 형식이라고 이해하시면 충분합니다. `CryptoKey`는 암호 키를 JavaScript 객체로 표현하기 위한 웹 표준 타입입니다. `importJwk()` 함수는 JWK 형식으로 표현된 키를 `CryptoKey` 객체로 변환합니다. `exportJwk()` 함수의 반대라고 이해하시면 됩니다. 자, 그럼 이제 다시 `setActorDispatcher()` 메서드로 눈을 돌립시다. `getActorKeyPairs()`라는 메서드가 쓰이고 있는데, 이 메서드는 이름과 같이 액터의 키 쌍들을 반환합니다. 액터의 키 쌍들은 바로 앞에서 살펴본 `setKeyPairsDispatcher()` 메서드로 불러온 바로 그 키 쌍들입니다. 우리는 RSA-PKCS#1-v1.5와 Ed25519 형식으로 된 두 쌍의 키를 불러왔으므로, `getActorKeyPairs()` 메서드는 두 키 쌍의 배열을 반환합니다. 각 배열의 원소는 키 쌍을 여러 형식으로 표현한 객체인데, 다음과 같이 생겼습니다: ~~~~ typescript interface ActorKeyPair { privateKey: CryptoKey; // 개인 키 publicKey: CryptoKey; // 공개 키 keyId: URL; // 키의 고유 식별 URI cryptographicKey: CryptographicKey; // 공개 키의 다른 형식 multikey: Multikey; // 공개 키의 또 다른 형식 } ~~~~ `CryptoKey`와 `CryptographicKey`와 `Multikey`가 각각 어떻게 다른지, 왜 이렇게 여러 형식이 있어야 하는지는 이 자리에서 설명하기엔 복잡합니다. 다만 지금은 `Person` 객체를 초기화할 때 `publicKey` 속성은 `CryptographicKey` 형식을 받고 `assertionMethods` 속성은 `MultiKey[]` (`Multikey`의 배열을 TypeScript에서 이렇게 표기) 형식을 받는다는 것만 짚고 넘어가도록 합시다. 그나저나, `Person` 객체에는 왜 공개 키를 갖는 속성이 `publicKey`와 `assertionMethods`로 두 개나 있을까요? ActivityPub에는 원래 `publicKey` 속성만 있었지만, 나중에 여러 키를 등록할 수 있도록 `assertionMethods` 속성이 추가되었습니다. 앞서 RSA-PKCS#1-v1.5 형식과 Ed25519 형식의 키를 모두 생성했던 것과 비슷한 이유로, 여러 소프트웨어와의 호환성을 위해 두 속성 모두 설정하는 것입니다. 자세히 보면, 레거시 속성인 `publicKey`에는 레거시 키 형식인 RSA-PKCS#1-v1.5 키만 등록하고 있다는 것을 알 수 있습니다 (배열의 첫 번째 항목에 RSA-PKCS#1-v1.5 키 쌍이, 두 번째 항목에 Ed25519 키 쌍이 들어감). > [!TIP] > 사실 `publicKey` 속성도 여러 키를 담을 수는 있습니다. 그렇지만 많은 소프트웨어들이 이미 `publicKey` 속성에는 단 하나의 키만 들어갈 것이라는 전제 하에 구현되었기 때문에 오작동할 때가 많습니다. 이를 피하기 위해 `assertionMethods`라는 새로운 속성이 제안된 것입니다. > > 이에 관해 관심이 생기신 분들은 [FEP-521a] 문서를 참고하세요. [FEP-521a]: https://w3id.org/fep/521a *[JWK]: JSON Web Key ### 테스트 자, 액터 객체에 암호 키들을 등록했으므로 잘 동작하는지 확인하도록 합시다. 다음 명령으로 액터를 조회합니다. ~~~~ sh fedify lookup http://localhost:8000/users/johndoe ~~~~ 정상적으로 동작한다면 아래와 같은 결과가 출력됩니다: ~~~~ console ✔ Looking up the object... Person { id: URL "http://localhost:8000/users/johndoe", name: "John Doe", url: URL "http://localhost:8000/users/johndoe", preferredUsername: "johndoe", publicKey: CryptographicKey { id: URL "http://localhost:8000/users/johndoe#main-key", owner: URL "http://localhost:8000/users/johndoe", publicKey: CryptoKey { type: "public", extractable: true, algorithm: { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: Uint8Array(3) [ 1, 0, 1 ], hash: { name: "SHA-256" } }, usages: [ "verify" ] } }, assertionMethods: [ Multikey { id: URL "http://localhost:8000/users/johndoe#main-key", controller: URL "http://localhost:8000/users/johndoe", publicKey: CryptoKey { type: "public", extractable: true, algorithm: { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: Uint8Array(3) [ 1, 0, 1 ], hash: { name: "SHA-256" } }, usages: [ "verify" ] } }, Multikey { id: URL "http://localhost:8000/users/johndoe#key-2", controller: URL "http://localhost:8000/users/johndoe", publicKey: CryptoKey { type: "public", extractable: true, algorithm: { name: "Ed25519" }, usages: [ "verify" ] } } ], inbox: URL "http://localhost:8000/users/johndoe/inbox", endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" } } ~~~~ `Person` 객체의 `publicKey` 속성에는 RSA-PKCS#1-v1.5 형식의 `CryptographicKey` 객체 하나가, `assertionMethods` 속성에는 RSA-PKCS#1-v1.5 형식과 Ed25519 형식의 `Multikey` 객체가 둘 들어있는 것을 확인할 수 있습니다. ## Mastodon과 연동 이제 실제 Mastodon에서 우리가 만든 액터를 볼 수 있는지 확인해 봅시다. ### 공개 인터넷에 노출 아쉽게도 현재 서버는 로컬에서만 접근이 가능합니다. 하지만 코드를 수정할 때마다 어딘가에 배포해서 테스트하는 것은 불편하겠죠. 배포하지 않고 바로 인터넷에 로컬 서버를 노출하여 테스트해 볼 수 있다면 얼마나 좋을까요? 여기, `fedify tunnel`이 그럴 때 쓰는 명령어입니다. 터미널에서 새 탭을 연 뒤, 이 명령어 뒤에 로컬 서버의 포트 번호를 입력하면 됩니다: ~~~~ sh fedify tunnel 8000 ~~~~ 그러면 한 번 쓰고 버릴 도메인 이름을 만들어서 로컬 서버로 중계를 합니다. 외부에서도 접근할 수 있는 URL이 출력될 것입니다: ~~~~ console ✔ 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>(여러분의 고유 임시 도메인으로 치환하세요)를 열어서 잘 접속되는지 확인할 수 있습니다: ![공개 인터넷으로 노출된 프로필 페이지](https://hackmd.io/_uploads/r1HqH9WnA.png) 위 웹 페이지에 보이는 여러분의 연합우주 핸들을 복사한 뒤, Mastodon에 들어가 좌상단에 위치한 검색창에 붙여넣고 검색을 해 보세요: ![Mastodon에서 연합우주 핸들로 검색한 결과](https://hackmd.io/_uploads/SJS3U9W2R.png) 위와 같이 검색 결과에 우리가 만든 액터가 보이면 정상입니다. 검색 결과에서 액터의 이름을 눌러서 프로필 페이지로 들어갈 수도 있습니다: ![Mastodon에서 보는 액터의 프로필](https://hackmd.io/_uploads/By9XwqWnR.png) 하지만 여기까지입니다. 아직 팔로는 할 수 없으니 시도하지 마세요! 다른 서버에서 우리가 만든 액터를 팔로할 수 있으려면, 수신함을 구현해야 합니다. > [!NOTE] > `fedify tunnel` 명령은 한동안 쓰이지 않으면 저절로 연결이 끊깁니다. 그럴 때는, <kbd>Ctrl</kbd>+<kbd>C</kbd> 키를 눌러 끈 다음, `fedify tunnel 8000` 명령을 다시 쳐서 새로운 연결을 맺어야 합니다. ## 수신함 ActivityPub에서 수신함(inbox)은 액터가 다른 액터로부터 액티비티를 받는 엔드포인트입니다. 모든 액터는 자신의 수신함을 가지고 있으며, 이는 HTTP `POST` 요청을 통해 액티비티를 받을 수 있는 URL입니다. 다른 액터가 팔로 요청을 보내거나, 글을 쓰거나, 댓글을 다는 등의 상호작용을 할 때 해당 액티비티는 수신자의 수신함으로 전달됩니다. 서버는 수신함으로 들어온 액티비티를 처리하고 적절히 응답함으로써 다른 액터들과 소통하고 연합 네트워크의 일부로 기능하게 됩니다. 수신함은 여러 종류의 액티비티를 수신할 수 있지만, 지금은 팔로 요청을 받는 것부터 구현하겠습니다. ### 테이블 생성 자신을 팔로하는 액터들(팔로워)과 자신이 팔로하는 액터들(팔로잉)을 담기 위해 *src/schema.sql* 파일에 `follows` 테이블을 정의합니다: ~~~~ sqlite 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` 테이블을 생성합시다: ~~~~ sh sqlite3 microblog.sqlite3 < src/schema.sql ~~~~ *src/schema.ts* 파일을 열고 `follows` 테이블에 저장되는 레코드를 JavaScript에서 표현하기 위한 타입도 정의합니다: ~~~~ typescript export interface Follow { following_id: number; follower_id: number; created: string; } ~~~~ ### `Follow` 액티비티 수신 이제 수신함을 구현할 차례입니다. 실은 앞서 이미 *src/federation.ts* 파일에 다음과 같은 코드를 작성한 바 있습니다: ~~~~ typescript federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); ~~~~ 위 코드를 수정하기에 앞서, Fedify가 제공하는 `Accept` 및 `Follow` 클래스와 `getActorHandle()` 함수를 `import`합니다: ~~~~ typescript import { Accept, // 추가됨 Endpoints, Follow, // 추가됨 Person, createFederation, exportJwk, generateCryptoKeyPair, getActorHandle, // 추가됨 importJwk, } from "@fedify/fedify"; ~~~~ 그리고 `setInboxListeners()` 메서드를 호출하는 코드를 아래와 같이 고칩니다: ~~~~ typescript 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 }, ); } 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 <!-- TODO: 터널 끊겼을 수 있음 --> 자, 그럼 팔로 요청이 제대로 수신되는지 확인할 차례입니다. 보통의 Mastodon 서버에서 테스트를 해도 괜찮긴 하지만, 액티비티가 구체적으로 어떻게 오가는지 확인할 수 있는 [ActivityPub.Academy] 서버를 이용하도록 합니다. ActivityPub.Academy는 교육 및 디버깅 용도의 특수한 Mastodon 서버인데, 클릭 한 번으로 임시 계정을 쉽게 만들 수 있습니다. ![ActivityPub.Academy 첫 페이지](https://hackmd.io/_uploads/SyHn53-hA.jpg) 개인 정보 보호 정책에 동의한 뒤 *등록하기* 버튼을 눌러 새 계정을 생성합니다. 생성된 계정은 무작위로 지어진 이름과 핸들을 갖게 되며, 하루가 지나면 알아서 사라집니다. 대신, 계정은 또 새로 생성할 수 있습니다. 로그인이 되고 나면 화면의 좌상단에 위치한 검색창에 우리가 만든 액터의 핸들을 붙여넣고 검색합니다: ![ActivityPub.Academy에서 우리가 만든 액터의 핸들로 검색한 결과](https://hackmd.io/_uploads/HJp7phZhC.png) 우리가 만든 액터가 검색 결과에 표시되면, 오른쪽에 있는 팔로 버튼을 눌러서 팔로 요청을 보냅니다. 그리고 우측 메뉴에서 *Activity Log*를 누릅니다: ![ActivityPub.Academy의 Activity Log](https://hackmd.io/_uploads/rymgAh-hR.png) 그럼 방금 팔로 버튼을 누름으로써 ActivityPub.Academy 서버에서 우리가 만든 액터의 수신함으로 `Follow` 액티비티가 전송되었다는 표시가 보입니다. 우하단의 *show source*를 누르면 액티비티의 내용까지 볼 수 있습니다: ![Activity Log에서 show source를 누른 화면](https://hackmd.io/_uploads/Hy6qR3-n0.png) [ActivityPub.Academy]: https://activitypub.academy/ ### 테스트 액티비티가 잘 전송되었다는 걸 확인했으니, 실제로 저희가 짠 수신함 코드가 잘 동작했는지 확인할 차례입니다. 먼저 `follows` 테이블에 레코드가 잘 만들어졌는지 봅시다: ~~~~ sh echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3 ~~~~ 팔로 요청이 잘 처리되었다면, 다음과 같은 결과가 나옵니다 (물론, 시각은 다르겠죠?): | `following_id` | `follower_id` | `created` | |----------------|---------------|-----------------------| | `1` | `2` | `2024-09-01 10:19:41` | 과연 `actors` 테이블에도 새 레코드가 생겼는지 확인합시다: ~~~~ sh 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) 액티비티](https://hackmd.io/_uploads/r18-7abnR.png) 자, 이렇게 여러분은 처음으로 ActivityPub을 통한 상호작용을 구현해냈습니다! ## 팔로 취소 다른 서버의 액터가 우리가 만든 액터를 팔로했다가 다시 취소하면 어떻게 될까요? 한 번 [ActivityPub.Academy]에서 시험해 봅시다. 아까와 마찬가지로 ActivityPub.Academy 검색창에 우리가 만든 액터의 연합우주 핸들을 입력하여 검색합니다: ![ActivityPub.Academy의 검색 결과](https://hackmd.io/_uploads/SykVrZG2R.png) 자세히 보면 액터 이름 오른쪽에 있던 팔로 버튼 자리에 언팔로(unfollow) 버튼이 있습니다. 이 버튼을 눌러서 팔로를 해제한 뒤, *Activity Log*에 들어가서 어떤 액티비티가 전송되나 확인해 봅시다: ![발신된 Undo(Follow) 액티비티가 보이는 Activity Log](https://hackmd.io/_uploads/S1lWIbMnC.png) 위와 같이 `Undo(Follow)` 액티비티가 전송되었습니다. 우하단의 *show source*를 누르면 액티비티의 자세한 내용을 볼 수 있습니다: ~~~~ json { "@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`합니다: ~~~~ typescript import { Accept, Endpoints, Follow, Person, Undo, // 추가됨 createFederation, exportJwk, generateCryptoKeyPair, getActorHandle, importJwk, } from "@fedify/fedify"; ~~~~ 그리고 `on(Follow, ...)` 뒤에 연달아 `on(Undo, ...)`를 추가합니다: ~~~~ typescript 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` 테이블을 비웁시다: ~~~~ sh echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3 ~~~~ 그리고 다시 팔로 버튼을 누른 뒤, 데이터베이스를 확인합니다: ~~~~ sh echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3 ~~~~ 팔로 요청이 잘 처리되었다면, 다음과 같은 결과가 나옵니다: | `following_id` | `follower_id` | `created` | |----------------|---------------|-----------------------| | `1` | `2` | `2024-09-02 01:05:17` | 그리고 다시 언팔로 버튼을 누른 뒤, 데이터베이스를 한 번 더 확인합니다: ~~~~ sh echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3 ~~~~ 언팔로 요청이 잘 처리되었다면, 레코드가 사라졌으므로 다음과 같은 결과가 나옵니다: | `count(*)` | |------------| | `0` | ## 팔로워 목록 매번 팔로워 목록을 `sqlite3` 명령으로 보는 건 성가시니, 웹으로 팔로워 목록을 볼 수 있게 합시다. 우선 *src/views.tsx* 파일에 새로운 컴포넌트를 추가하는 것으로 시작합니다. `Actor` 타입을 `import`해주세요: ~~~~ typescript import type { Actor } from "./schema.ts"; ~~~~ 그리고 `<FollowerList>` 컴포넌트와 `<ActorLink>` 컴포넌트를 정의합니다: ~~~~ tsx 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`합니다: ~~~~ typescript import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx"; ~~~~ 그리고 `GET /users/{username}/followers`에 대한 요청 핸들러를 추가합니다: ~~~~ tsx 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> 페이지를 열면, 아래와 같이 보일 것입니다: ![팔로워 목록 페이지](https://hackmd.io/_uploads/HycrQsf3C.png) 팔로워 목록을 만들었으니 프로필 페이지에서 팔로워 수도 표시하면 좋을 것 같습니다. *src/views.tsx* 파일을 다시 열고 `<Profile>` 컴포넌트를 아래와 같이 고칩니다: ~~~~ tsx 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}` 요청 핸들러를 다음과 같이 수정합니다: ~~~~ tsx 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> 페이지를 열면 아래와 같이 보일 것입니다: ![바뀐 프로필 페이지](https://hackmd.io/_uploads/ByOxBe72A.png) ## 팔로워 컬렉션 그런데 한 가지 문제가 있습니다. ActivityPub.Academy가 *아닌* 다른 Mastodon 서버에서 우리가 만든 액터를 조회해봅시다. (조회하는 법은 이제 다 아시죠? 공개 인터넷에 노출된 상태에서, 액터 핸들을 Mastodon 검색창에 치면 됩니다.) Mastodon에서 우리가 만든 액터의 프로필을 보면 아마도 이상한 점을 눈치 챌 수 있을 것입니다: ![Mastodon에서 조회한 우리가 만든 액터의 프로필](https://hackmd.io/_uploads/r1ea7nz3A.png) 바로 팔로워 수가 0으로 나온다는 것입니다. 이는 우리가 만든 액터가 팔로워 목록을 ActivityPub을 통해 노출하고 있지 않기 때문입니다. ActivityPub에서 팔로워 목록을 노출하려면 팔로워 컬렉션을 정의해야 합니다. *src/federation.ts* 파일을 열어 Fedify가 제공하는 `Recipient` 타입을 `import`합니다: ~~~~ typescript import { Accept, Endpoints, Follow, Person, Undo, createFederation, exportJwk, generateCryptoKeyPair, getActorHandle, importJwk, type Recipient, // 추가됨 } from "@fedify/fedify"; ~~~~ 그리고 아래쪽에 팔로워 컬렉션 디스패처를 추가합니다: ~~~~ typescript 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` 타입은 다음과 같이 생겼습니다: ~~~~ typescript 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` 명령을 사용합시다: ~~~~ sh fedify lookup http://localhost:8000/users/johndoe/followers ~~~~ 제대로 구현되었다면 아래와 같은 결과가 나올 것입니다: ~~~~ ✔ Looking up the object... OrderedCollection { totalItems: 1, items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ] } ~~~~ 그런데, 이렇게 팔로워 컬렉션을 만들어 놓기만 해서는 다른 서버에서 팔로워 컬렉션이 어디 있는지 알 수 없습니다. 그래서 액터 디스패처에서 팔로워 컬렉션에 링크를 걸어 줘야 합니다: ~~~~ typescript federation .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { // ... 생략 ... return new Person({ // ... 생략 ... followers: ctx.getFollowersUri(identifier), }); }) ~~~~ 액터도 `fedify lookup`으로 조회하여 봅시다: ~~~~ sh 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에서 다시 조회한 우리가 만든 액터의 프로필](https://hackmd.io/_uploads/r1ea7nz3A.png) 팔로워 수는 여전히 0으로 나오기 때문이죠. 이는 Mastodon이 다른 서버의 액터 정보를 캐시(cache)하고 있기 때문입니다. 이를 업데이트하는 방법이 있긴 하지만 <kbd>F5</kbd> 키를 누르는 것처럼 쉽지는 않습니다: - 한 가지 방법은 일주일을 기다리는 것입니다. Mastodon은 다른 서버의 액터 정보를 담는 캐시를 마지막 업데이트 이후 7일이 지날 때 날리기 때문입니다. - 또 다른 방법은 `Update` 액티비티를 날리는 것인데, 귀찮은 코딩을 필요로 합니다. - 아니면 아직 캐시가 되지 않은 다른 Mastodon 서버에서 조회해 보는 것도 한 방법이겠죠. - 마지막 방법은 `fedify tunnel`을 껐다 켜서 새로운 임시 도메인을 할당 받는 것입니다. 여러분이 다른 Mastodon 서버에서 정확한 팔로워 수가 표시되는 것을 직접 확인하고 싶으시다면 제가 나열한 방법들 중 하나를 시도해 보시기 바랍니다. ## 게시물 자, 이제 드디어 게시물을 구현할 때가 왔습니다. 일반적인 블로그와 달리 우리가 만들 마이크로블로그는 다른 서버에서 작성된 게시물도 저장할 수 있어야 합니다. 이를 염두에 두고 설계해 봅시다. ### 테이블 생성 바로 `posts` 테이블부터 만듭시다. *src/schema.sql* 파일을 열어 아래 SQL을 추가합니다: ~~~~ sqlite 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` 테이블을 생성합시다: ~~~~ sh sqlite3 microblog.sqlite3 < src/schema.sql ~~~~ `posts` 테이블에 저장될 레코드를 JavaScript로 표현하는 `Post` 타입도 *src/schema.ts* 파일에 정의합니다: ~~~~ typescript export interface Post { id: number; uri: string; actor_id: number; content: string; url: string | null; created: string; } ~~~~ ### 첫 페이지 게시물을 작성하려면 양식이 어딘가에 있어야겠죠? 그러고 보니, 아직까지 첫 페이지도 제대로 만들지 않았습니다. 첫 페이지에 게시물 작성 양식을 추가하겠습니다. 먼저 *src/views.tsx* 파일을 열어 `User` 타입을 `import`합니다: ~~~~ typescript import type { Actor, User } from "./schema.ts"; ~~~~ 그리고 `<Home>` 컴포넌트를 정의합니다: ~~~~ tsx 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`합니다: ~~~~ typescript import { FollowerList, Home, Layout, Profile, SetupForm } from "./views.tsx"; ~~~~ 그리고 이미 있는 `GET /` 요청 핸들러를: ~~~~ typescript app.get("/", (c) => c.text("Hello, Fedify!")); ~~~~ 아래와 같이 고쳐줍니다: ~~~~ tsx 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/> 페이지를 열면 아래와 같이 보여야 합니다: ![첫 페이지](https://hackmd.io/_uploads/HJF35y7nR.png) ### 레코드 삽입 게시물 작성 양식을 만들었으니, 실제로 게시물 내용을 `posts` 테이블에 저장하는 코드가 필요합니다. 먼저 *src/federation.ts* 파일을 열어 Fedify가 제공하는 `Note` 클래스를 `import`합니다: ~~~~ typescript import { Accept, Endpoints, Follow, Note, // 추가됨 Person, Undo, createFederation, exportJwk, generateCryptoKeyPair, getActorHandle, importJwk, type Recipient, } from "@fedify/fedify"; ~~~~ 아래 코드를 추가합니다: ~~~~ typescript federation.setObjectDispatcher( Note, "/users/{identifier}/posts/{id}", (ctx, values) => { return null; }, ); ~~~~ 위 코드는 아직 별 역할을 하진 않지만, 게시물의 퍼머링크 형식을 정하는 데에 필요합니다. 실제 구현은 나중에 하도록 하겠습니다. ActivityPub에서는 게시물의 내용을 HTML 형식으로 주고받습니다. 따라서 평문 형식으로 입력 받은 내용을 HTML 형식으로 변환해야 합니다. 이 때, `<`, `>`와 같은 문자들을 HTML에서 표시할 수 있도록 `&lt;`, `&gt;`와 같은 HTML 엔티티로 변환해주는 *[stringify-entities]* 패키지가 필요합니다: ~~~~ sh npm add stringify-entities ~~~~ 그리고 *src/app.tsx* 파일을 열어 설치한 패키지를 `import`합니다. ~~~~ typescript import { stringifyEntities } from "stringify-entities"; ~~~~ `Post` 타입과 Fedify가 제공하는 `Note` 클래스도 `import`합니다: ~~~~ typescript import type { Actor, Post, User } from "./schema.ts"; import { Note } from "@fedify/fedify"; ~~~~ 그리고 `POST /users/{username}/posts` 요청 핸들러를 구현합니다: ~~~~ typescript 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/> 페이지를 연 뒤, 게시물을 작성해 봅시다: ![게시물 작성중](https://hackmd.io/_uploads/B1ObQeQhR.png) *Post* 버튼을 눌러 게시물을 작성하면, 안타깝게도 `404 Not Found` 오류가 납니다: ![404 Not Found](https://hackmd.io/_uploads/B1GrmxQ2R.png) 왜냐하면 게시물 퍼머링크로 리다이렉트하도록 구현했는데, 아직 게시물 페이지를 구현하지 않았기 때문입니다. 하지만, 그래도 `posts` 테이블에는 레코드가 만들어졌을 것입니다. 한 번 확인해 봅시다: ~~~~ sh 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` | [stringify-entities]: https://github.com/wooorm/stringify-entities ### 게시물 페이지 게시물 작성 후 `404 Not Found` 오류가 나지 않도록, 게시물 페이지를 구현합시다. *src/views.tsx* 파일을 열어 `Post` 타입을 `import`합니다: ~~~~ typescript import type { Actor, Post, User } from "./schema.ts"; ~~~~ 그리고 `<PostPage>` 컴포넌트 및 `<PostView>` 컴포넌트를 정의합니다: ~~~~ tsx 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`합니다: ~~~~ typescript import { FollowerList, Home, Layout, PostPage, // 추가됨 Profile, SetupForm, } from "./views.tsx"; ~~~~ 그리고 `GET /users/{username}/posts/{id}` 요청 핸들러를 구현합니다: ~~~~ tsx 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> 페이지를 웹 브라우저에서 열어 봅시다: ![게시물 페이지](https://hackmd.io/_uploads/H1VIvlmhR.png) ### `Note` 객체 디스패처 그럼 이제 게시물을 다른 Mastodon 서버에서 조회할 수 있나 확인해 볼까요? 먼저 `fedify tunnel`을 이용하여 로컬 서버를 공개 인터넷에 노출합니다. 그 상태에서, Mastodon 검색창에 글의 퍼머링크인 <https://temp-address.serveo.net/users/johndoe/posts/1>(여러분의 임시 도메인 이름으로 치환하세요)을 쳐봅시다: ![빈 검색 결과](https://hackmd.io/_uploads/SyEdclmhC.png) 안타깝게도 검색 결과는 비어 있습니다. 게시물을 ActivityPub 객체 형식으로 노출하지 않았기 때문입니다. 그럼 게시물을 ActivityPub 객체로 노출해 봅시다. 구현에 앞서 필요한 라이브러리를 설치해야 합니다. Fedify에서 시각을 표현하는 데에 쓰는 [Temporal API]가 아직 Node.js에 내장되어 있지 않기 때문에 이를 폴리필(polyfill)해주는 *[@js-temporal/polyfill]* 패키지가 필요합니다: ~~~~ sh npm add @js-temporal/polyfill ~~~~ *src/federation.ts* 파일을 열어 설치한 패키지를 `import`합니다: ~~~~ typescript import { Temporal } from "@js-temporal/polyfill"; ~~~~ `Post` 타입과 Fedify가 제공하는 `PUBLIC_COLLECTION` 상수도 `import`합니다. ~~~~ typescript 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` 클래스에 대한 객체 디스패처는 이미 빈 구현이나마 만들어 두었었죠: ~~~~ typescript federation.setObjectDispatcher( Note, "/users/{identifier}/posts/{id}", (ctx, values) => { return null; }, ); ~~~~ 이를 아래와 같이 고칩니다: ~~~~ typescript 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 검색 결과에 우리가 작성한 게시물이 보인다.](https://hackmd.io/_uploads/rk6mkZXnR.png) 이번에는 검색 결과에 제대로 우리가 작성한 게시물이 나오네요! [Temporal API]: https://tc39.es/proposal-temporal/docs/ [@js-temporal/polyfill]: https://github.com/js-temporal/temporal-polyfill ### `Create(Note)` 액티비티 발신 하지만 Mastodon에서 우리가 만든 액터를 팔로 해도, 새로 작성한 게시물이 Mastodon 타임라인에 올라오지는 않습니다. 왜냐하면 Mastodon이 새 게시물을 알아서 받아가는 게 아니라, 새 게시물을 작성한 쪽에서 `Create(Note)` 액티비티를 전송하여 새 게시물이 만들어졌다는 것을 알려줘야 하기 때문입니다. 게시물 생성시에 `Create(Note)` 액티비티를 전송하도록 코드를 고쳐봅시다. *src/app.tsx* 파일을 열어 Fedify가 제공하는 `Create` 클래스를 `import`합니다: ~~~~ typescript import { Create, Note } from "@fedify/fedify"; ~~~~ 그리고 `POST /users/{username}/posts` 요청 핸들러를 다음과 같이 수정합니다: ~~~~ typescript 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를 지어내서 설정합니다. > [!TIP] > 액티비티 객체의 `id` 속성에는 반드시 접근 가능한 URI가 들어갈 필요는 없습니다. 그저 고유하기만 하면 됩니다. `sendActivity()` 메서드의 두 번째 파라미터에는 수신자가 들어가는데, 여기서는 `"followers"`라는 특수한 옵션을 지정했습니다. 이 옵션을 지정하면 앞서 구현했던 팔로워 컬렉션 디스패처를 이용하여 모든 팔로워들에게 액티비티를 전송하게 됩니다. 자, 구현을 끝냈으니 `Create(Note)` 액티비티가 잘 전송되나 확인해 볼까요? `fedify tunnel` 명령으로 로컬 서버를 공개 인터넷에 노출시킨 채, [ActivityPub.Academy]로 들어가 *@johndoe@temp-address.serveo.net*(도메인 이름은 여러분에게 할당된 임시 도메인 이름으로 치환하세요)를 팔로합니다. 팔로워 목록에서 팔로 요청이 확실히 수락된 것을 확인한 뒤, 웹 브라우저에서 <https://temp-address.serveo.net/>(마찬가지로, 도메인 이름은 치환하세요) 페이지를 들어가 새 게시물을 작성합니다. > [!WARNING] > 액티비티 전송을 테스트할 때는 반드시 *localhost*가 아닌 공개 인터넷에서 접근 가능한 도메인 이름으로 접속해야 합니다. ActivityPub 객체의 ID를 결정할 때 요청이 들어온 도메인 이름을 기준으로 URI를 구하기 때문입니다. `Create(Note)` 액티비티가 잘 갔는지 확인하기 위해, ActivityPub.Academy의 *Activity Log*를 살펴봅시다: ![수신된 Create(Note) 액티비티가 보이는 Activity Log](https://hackmd.io/_uploads/BJt82MQ3A.png) 잘 들어왔네요. 그럼 ActivityPub.Academy에서 타임라인을 살펴봅시다: ![ActivityPub.Academy의 타임라인에서 생성한 게시물이 잘 보인다.](https://hackmd.io/_uploads/r1O62G73C.png) 해냈습니다! ## 프로필 페이지 내 게시물 목록 현재 프로필 페이지에는 이름과 연합우주 핸들, 팔로워 수만 나올 뿐 정작 게시물은 보이지 않습니다. 프로필 페이지에서 작성한 게시물을 보여줍시다. *src/views.tsx* 파일을 열고 `<PostList>` 컴포넌트를 추가합니다: ~~~~ tsx 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`합니다: ~~~~ typescript import { FollowerList, Home, Layout, PostList, // 추가됨 PostPage, Profile, SetupForm, } from "./views.tsx"; ~~~~ 이미 있는 `GET /users/{username}` 요청 핸들러를 다음과 같이 변경합니다: ~~~~ tsx 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> 페이지를 열어봅시다: ![변경된 프로필 페이지](https://hackmd.io/_uploads/SyKqQmXhR.png) 작성한 게시물들이 잘 나오는 것을 볼 수 있습니다. ## 팔로 현재 우리가 만든 액터는 다른 서버의 액터로부터 팔로 요청을 받을 수는 있지만, 다른 서버의 액터에게 팔로 요청을 보낼 수는 없습니다. 팔로를 할 수 없으니 다른 액터가 작성한 게시물도 볼 수 없습니다. 자, 그럼 다른 서버의 액터에 팔로 요청을 보내는 기능을 추가합시다. UI 먼저 만듭시다. *src/views.tsx* 파일을 열고, 이미 있는 `<Home>` 컴포넌트를 다음과 같이 수정합니다: ~~~~ tsx 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가 생긴 첫 화면](https://hackmd.io/_uploads/SksZAmm2R.png) ### `Follow` 액티비티 전송 팔로 요청 UI가 생겼으니 실제로 `Follow` 액티비티를 전송하는 코드를 짤 차례입니다. *src/app.tsx* 파일을 열고 Fedify가 제공하는 `Follow` 클래스와 `isActor()` 및 `lookupObject()` 함수를 `import`합니다: ~~~~ typescript import { Create, Follow, // 추가됨 isActor, // 추가됨 lookupObject, // 추가됨 Note, } from "@fedify/fedify"; ~~~~ 그리고 `POST /users/{username}/following` 요청 핸들러를 추가합니다: ~~~~ typescript 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 actor = await lookupObject(handle.trim()); if (!isActor(actor)) { return c.text("Invalid actor handle or URL", 400); } const ctx = fedi.createContext(c.req.raw, undefined); 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가 있는 첫 화면](https://hackmd.io/_uploads/SksZAmm2R.png) 팔로 요청 입력창에 팔로할 액터의 연합우주 핸들을 입력해야 합니다. 여기서는 쉬운 디버깅을 위해 [ActivityPub.Academy]의 액터를 입력하도록 합시다. 참고로, ActivityPub.Academy에서 로그인 된 임시 계정의 핸들은 임시 계정의 이름을 클릭하여 프로필 페이지에 들어가면 이름 바로 아래에서 볼 수 있습니다: ![ActivityPub.Academy의 계정 프로필 페이지 상에 보이는 연합우주 핸들](https://hackmd.io/_uploads/rkSCzVQhA.png) 다음과 같이 ActivityPub.Academy의 액터 핸들을 입력한 뒤, *Follow* 버튼을 눌러 팔로 요청을 보냅니다: ![ActivityPub.Academy의 액터로 팔로 요청을 보내는 중](https://hackmd.io/_uploads/By55m4m2R.png) 그리고 ActivityPub.Academy의 *Activity Log*를 확인합니다: ![ActivityPub.Academy의 Activity Log](https://hackmd.io/_uploads/rkPhENXn0.png) *Activity Log*에는 우리가 전송한 `Follow` 액티비티와, ActivityPub.Academy로부터 전송된 답장인 `Accept(Follow)` 액티비티가 표시됩니다. ActivityPub.Academy의 알림 페이지로 가면 실제로 팔로 요청이 도착한 것을 확인할 수 있습니다: ![ActivityPub.Academy의 알림 페이지 상에 나타난 도착한 팔로 요청](https://hackmd.io/_uploads/HJHLHVXn0.png) ### `Accept(Follow)` 액티비티 수신 하지만 아직 수신된 `Accept(Follow)` 액티비티에 대해 아무런 행동도 취하고 있지 않기 때문에, 이 부분을 구현해야 합니다. *src/federation.ts* 파일을 열어 Fedify에서 제공하는 `isActor()` 함수 및 `Actor` 타입을 `import`합니다: ~~~~ typescript 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` 테이블에 넣는 코드를 리팩터링하여 재사용 가능하게 바꿔봅시다. 아래 함수를 추가합니다: ~~~~ typescript 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()` 함수를 쓰게 바꿉니다: ~~~~ typescript 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)` 액티비티를 받았을 때 취할 행동을 구현합니다: ~~~~ typescript .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](https://hackmd.io/_uploads/SJavuBQnA.png) 아직은 팔로잉 목록을 구현하지 않았으므로, `follows` 테이블에 레코드가 제대로 들어갔나 직접 확인을 해 봅시다: ~~~~ sh 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>` 컴포넌트를 추가합니다: ~~~~ tsx 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`합니다: ~~~~ typescript import { FollowerList, FollowingList, // 추가됨 Home, Layout, PostList, PostPage, Profile, SetupForm, } from "./views.tsx"; ~~~~ 그리고 `GET /users/{username}/following` 요청에 대한 핸들러를 추가합니다: ~~~~ tsx 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> 페이지를 열어봅시다: ![팔로잉 목록](https://hackmd.io/_uploads/Hkj7CSQ3R.png) ## 팔로잉 수 팔로워 수를 보여주고 있는 것처럼, 팔로잉 수도 표시해야 합니다. *src/views.tsx* 파일을 열어 `<Profile>` 컴포넌트를 다음과 같이 수정합니다: ~~~~ tsx 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>` 컴포넌트도 다음과 같이 수정합니다: ~~~~ tsx 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}` 요청에 대한 핸들러를 다음과 같이 수정합니다: ~~~~ tsx 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}` 요청 핸들러도 수정합니다: ~~~~ tsx 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> 페이지를 열어 봅시다: ![프로필 페이지](https://hackmd.io/_uploads/ryvXGLXh0.png) ## 타임라인 많은 것들을 구현했지만, 아직 다른 Mastodon 서버에서 쓴 게시물이 보이지는 않고 있습니다. 여태까지의 과정에서 짐작할 수 있다시피, 우리가 게시물을 쓸 때 `Create(Note)` 액티비티를 발신했던 것과 같이, 다른 서버로부터 `Create(Note)` 액티비티를 수신해야 다른 Mastodon 서버에서 쓴 게시물이 보이게 됩니다. 다른 Mastodon 서버에서 글을 쓰면 구체적으로 어떤 일이 일어나는지 보기 위해, [ActivityPub.Academy]에서 새로운 게시물을 작성해 봅시다: ![ActivityPub.Academy에서 새 게시물을 작성중](https://hackmd.io/_uploads/B1iFHL72A.png) *Publish!* 버튼을 눌러 게시물을 저장한 뒤, *Activity Log* 페이지로 들어가 `Create(Note)` 액티비티가 과연 잘 발신되었나 확인합니다: ![발신된 Create(Note) 액티비티가 보이는 Activity Log](https://hackmd.io/_uploads/HkkwLIX20.png) 이제 이렇게 발신된 `Create(Note)` 액티비티를 수신하는 코드를 짜야 합니다. ### `Create(Note)` 액티비티 수신 *src/federation.ts* 파일을 열어 Fedify가 제공하는 `Create` 클래스를 `import`합니다: ~~~~ typescript 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, ...)`를 추가합니다: ~~~~ typescript .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` 테이블에 정말 레코드가 추가되었나 확인합니다: ~~~~ sh 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>` 컴포넌트를 수정합니다: ~~~~ tsx export interface HomeProps extends PostListProps { // extends 추가됨 user: User & Actor; } export const Home: FC<HomeProps> = ({ user, posts }) => ( <> {/* ... 생략 ... */} <PostList posts={posts} /> </> ); ~~~~ 그 뒤, *src/app.tsx* 파일을 열어 `GET /` 요청 핸들러를 수정합니다: ~~~~ tsx 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/> 페이지를 열어 타임라인을 감상합시다: ![첫 페이지에서 보이는 타임라인](https://hackmd.io/_uploads/rJYcJw7h0.png) 위와 같이 원격에서 작성한 게시물과 로컬에서 작성한 게시물이 최신순으로 잘 표시되는 것을 알 수 있습니다. 어떤가요? 마음에 드시나요? 이 튜토리얼에서 구현할 것은 이게 전부입니다. 이것을 바탕으로 여러분만의 마이크로블로그를 완성시키는 것도 가능할 것입니다. ## 개선할 점 이 튜토리얼을 통해 완성한 여러분의 마이크로블로그는 아쉽게도 아직 실사용에는 적합하지 않습니다. 특히, 보안 측면에서 취약점이 많이 있기 때문에 실제로 사용하는 것은 위험할 수 있습니다. 여러분이 만든 마이크로블로그를 좀 더 발전시키고 싶은 분들은, 아래 과제들을 직접 해결해 보셔도 좋을 것입니다: - 현재는 아무런 인증이 없기 때문에, 누구라도 URL만 알면 글을 게시할 수 있습니다. 로그인 과정을 추가하여 이를 방지해 볼까요? - 현재의 구현은 ActivityPub을 통해 받은 `Note` 객체 안에 들어 있는 HTML을 그대로 출력하게 되어 있습니다. 따라서 악의적인 ActivityPub 서버가 `<script>while (true) alert('메롱');</script>` 같은 HTML을 포함한 `Create(Note)` 액티비티를 보내는 공격을 할 수 있습니다. 이를 [XSS] 취약점이라고 합니다. 이러한 취약점은 어떻게 막을 수 있을까요? - SQLite 데이터베이스에서 다음 SQL을 실행하여 우리가 만든 액터의 이름을 바꿔 봅시다: ~~~~ sqlite UPDATE actors SET name = 'Renamed' WHERE id = 1; ~~~~ 이렇게 액터의 이름을 바꿨을 때, 다른 Mastodon 서버에서 바뀐 이름이 적용될까요? 적용되지 않는다면, 어떤 액티비티를 보내야 변경이 적용될까요? - 액터에 프로필 사진을 추가해 봅시다. 프로필 사진을 추가하는 방법이 궁금하다면, `fedify lookup` 명령으로 이미 프로필 사진이 있는 액터를 조회해 보세요. - 다른 Mastodon 서버에서 이미지가 첨부된 게시물을 작성해 봅시다. 우리가 만든 타임라인에서는 게시물에 첨부된 이미지가 보이지 않습니다. 어떻게 하면 첨부된 이미지를 표시할 수 있을까요? - 게시물 내에서 다른 액터를 멘션할 수 있게 해봅시다. 멘션한 상대한테 알림이 가도록 하려면 어떻게 해야 할까요? [ActivityPub.Academy]의 *Activity Log*를 활용하여 방법을 찾아보세요. [XSS]: https://ko.wikipedia.org/wiki/%EC%82%AC%EC%9D%B4%ED%8A%B8_%EA%B0%84_%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8C%85 *[XSS]: cross-site scripting