Note
만약 연합우주(fediverse)나 ActivityPub 같은 용어가 생소하다면, 관련 검색을 좀 더 하고 나서 이 튜토리얼을 따라할 것을 권합니다.
이 튜토리얼에서는 ActivityPub 서버 프레임워크인 Fedify를 이용하여 Mastodon이나 Misskey 같은 ActivityPub 프로토콜을 구현하는 마이크로블로그(microblog)를 만들어 보도록 하겠습니다. 이 튜토리얼은 Fedify의 기반 동작 원리를 파악하는 것보다는 Fedify의 활용법에 좀 더 집중하려고 합니다.
Fedify는 ActivityPub이나 그 외 표준(총칭하여 「연합우주」라 불리는)을 이용하여 연합 서버 앱을 만들기 위한 TypeScript 라이브러리입니다. 연합 서버 앱을 만들 때의 복잡함이나 번거로운 보일러플레이트 코드를 없애고, 비즈니스 로직과 사용자 경험에 집중할 수 있도록 하는 것이 Fedify의 목표입니다.
Fedify 프로젝트에 관심이 생기셨다면, 아래의 자료를 참고해 주세요:
Fedify나 본 튜토리얼에 대한 질문이나 제안, 피드백 등은 GitHub Discussions(영어)에 올려 주시거나 연합우주 @fedify@hollo.social(영어 및 한국어)로 멘션 주시기 바랍니다. 아니면 한국 연합우주 개발자 모임의 Discord 서버에 들어오셔서 #fedify 채널(한국어)에서 말씀하셔도 됩니다.
이 튜토리얼은 Fedify를 배워서 ActivityPub 서버 소프트웨어를 만들어 보고 싶은 분들을 대상으로 합니다.
여러분이 HTML이나 HTTP를 이용하여 웹앱을 제작해 본 경험이 있으며, 명령행 인터페이스나 SQL, JSON, 기본적인 JavaScript 등을 이해한다고 가정합니다. 하지만 TypeScript나 JSX, ActivityPub, Fedify 등은 이 튜토리얼에서 필요한 만큼 가르쳐 드릴 것이니 몰라도 괜찮습니다.
ActivityPub 소프트웨어를 만들어 본 경험은 필요 없지만, 그래도 Mastodon이나 Misskey 같은 ActivityPub 소프트웨어를 하나 정도는 써봤다고 가정합니다. 그래야 우리가 무엇을 만드려고 하는지 감이 잡히기 때문입니다.
이 튜토리얼에서는 Fedify를 이용해 ActivityPub으로 다른 연합형 소프트웨어 및 서비스와 소통 가능한 일인용 마이크로블로그를 만듭니다. 이 소프트웨어는 다음과 같은 기능을 포함합니다.
튜토리얼을 단순화하기 위해 다음과 같은 기능 제약을 둡니다.
물론, 튜토리얼을 끝까지 진행한 뒤 기능을 덧붙이는 것은 얼마든지 하셔도 좋습니다. 좋은 연습이 될 것입니다.
완성된 소스 코드는 GitHub 저장소에 올라와 있으며, 각 구현 단계에 따라 커밋이 나뉘어져 있으니 참고 바랍니다.
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
명령어가 생깁니다:
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
Note
Fedify는 풀 스택 프레임워크가 아닌, ActivityPub 서버 구현에 특화된 프레임워크입니다. 따라서, 다른 웹 프레임워크와 함께 쓰이는 것을 염두에 두고 만들어졌습니다. 이 튜토리얼에서는 웹 프레임워크로 Hono를 채택하여 Fedify와 함께 사용합니다.
그러면 잠시 후 작업 디렉터리 안에 다음과 같은 구조로 파일들이 생성되는 것을 확인할 수 있습니다:
짐작할 수 있겠지만, 우리는 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이라는 것을 알 수 있습니다.
Tip
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를 써보실 것을 권합니다. 왜냐하면 우리는 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 설정 여부에 따라 생산성의 차이가 크기 때문입니다.
코드를 수정하기 전에, 간단히 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와 함께 쓸 때 가장 경험이 좋으니까요.
Tip
TypeScript를 제대로 찬찬히 배워보고 싶으시다면, 공식 TypeScript 핸드북을 읽으실 것을 추천합니다. 전부 읽는데 약 30분 정도 소요됩니다.
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
인터페이스를 선언하고 사용했습니다.
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 사용하기 섹션을 읽어 보세요.
자, 이제 본격적인 개발에 돌입합시다.
가장 먼저 만들 것은 바로 계정 생성 페이지입니다. 계정을 만들어야 게시물도 올리고 다른 계정을 팔로 할 수도 있겠죠. 보이는 것부터 만들어 봅시다.
먼저 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 프레임워크를 사용하기로 합니다.
Tip
변수나 매개변수의 타입을 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 페이지를 열어 봅시다. 아래와 같은 화면이 보여야 정상입니다:
Note
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
Tip
@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;
Tip
참고로 db.pragma()
함수를 통해 한 설정은 다음과 같은 효과를 지닙니다:
journal_mode = WAL
foreign_keys = ON
그리고 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을 바꿔야 합니다) 페이지를 열어 보세요. 아래와 같은 화면이 나와야 합니다:
Tip
연합우주 핸들(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
Tip
앞서 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>,
);
});
Tip
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을 일치시키도록 하겠습니다.
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
인스턴스로 표현합니다.
그럼 한 번 액터 디스패처를 테스트해 볼까요?
서버가 켜진 상태에서, 새 터미널 탭을 열어 아래 명령을 입력합니다:
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가 알아서 해 주지만, 키 쌍을 생성하고 보존하는 것은 직접 구현하셔야 합니다.
Warning
개인 키(비밀 키)는 이름에서 드러나듯 서명할 주체 이외에는 접근할 수 없어야 합니다. 반면, 공개 키는 그 용도 자체가 공개하기 위함이므로 누구나 접근해도 괜찮습니다.
개인 키와 공개 키 쌍을 저장할 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)
에 걸려 있으니, 한 사용자에 대해 최대 두 쌍의 키가 있을 수 있습니다.
Tip
이 튜토리얼에서 자세히 설명할 수는 없지만, 2024년 9월 현재 ActivityPub 네트워크는 RSA-PKCS#1-v1.5 형식에서 Ed25519 형식으로 이행하고 있는 중이라고 알고 계시면 좋습니다. 어떤 소프트웨어는 RSA-PKCS#1-v1.5 형식만 받아들이고 어떤 소프트웨어는 Ed25519 형식을 받아들입니다. 따라서, 양쪽 모두와 소통하기 위해서는 두 쌍의 키가 모두 필요한 것입니다.
private_key
및 public_key
칼럼은 문자열을 받을 수 있게 되어 있는데, 우리는 여기에 JSON 데이터를 넣을 예정입니다. 개인 키와 공개 키를 JSON으로 인코딩하는 방법에 대해서는 뒤에서 차차 다루게 될 것입니다.
그럼 keys
테이블을 생성합시다:
sqlite3 microblog.sqlite3 < src/schema.sql
keys
테이블에 저장되는 레코드를 JavaScript로 표현할 Key
타입도 src/schema.ts 파일에 정의합니다:
export interface Key {
user_id: number;
type: "RSASSA-PKCS1-v1_5" | "Ed25519";
private_key: string;
public_key: string;
created: string;
}
이제 키 쌍을 생성하고 불러오는 코드를 짜야 합니다.
src/federation.ts 파일을 열고 Fedify에서 제공되는 exportJwk()
, generateCryptoKeyPair()
, importJwk()
함수들과 앞서 정의한 Key
타입을 import
합시다:
import {
Endpoints,
Person,
createFederation,
exportJwk, // 추가됨
generateCryptoKeyPair, // 추가됨
importJwk, // 추가됨
} from "@fedify/fedify";
import type { Actor, Key, User } from "./schema.ts";
그리고 액터 디스패처 부분을 다음과 같이 고칩니다:
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
const user = db
.prepare<unknown[], User & Actor>(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
WHERE users.username = ?
`,
)
.get(identifier);
if (user == null) return null;
const keys = await ctx.getActorKeyPairs(identifier);
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
name: user.name,
inbox: ctx.getInboxUri(identifier),
endpoints: new Endpoints({
sharedInbox: ctx.getInboxUri(),
}),
url: ctx.getActorUri(identifier),
publicKey: keys[0].cryptographicKey,
assertionMethods: keys.map((k) => k.multikey),
});
})
.setKeyPairsDispatcher(async (ctx, identifier) => {
const user = db
.prepare<unknown[], User>("SELECT * FROM users WHERE username = ?")
.get(identifier);
if (user == null) return [];
const rows = db
.prepare<unknown[], Key>("SELECT * FROM keys WHERE keys.user_id = ?")
.all(user.id);
const keys = Object.fromEntries(
rows.map((row) => [row.type, row]),
) as Record<Key["type"], Key>;
const pairs: CryptoKeyPair[] = [];
// 사용자가 지원하는 두 키 형식 (RSASSA-PKCS1-v1_5 및 Ed25519) 각각에 대해
// 키 쌍을 보유하고 있는지 확인하고, 없으면 생성 후 데이터베이스에 저장:
for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) {
if (keys[keyType] == null) {
logger.debug(
"The user {identifier} does not have an {keyType} key; creating one...",
{ identifier, keyType },
);
const { privateKey, publicKey } = await generateCryptoKeyPair(keyType);
db.prepare(
`
INSERT INTO keys (user_id, type, private_key, public_key)
VALUES (?, ?, ?, ?)
`,
).run(
user.id,
keyType,
JSON.stringify(await exportJwk(privateKey)),
JSON.stringify(await exportJwk(publicKey)),
);
pairs.push({ privateKey, publicKey });
} else {
pairs.push({
privateKey: await importJwk(
JSON.parse(keys[keyType].private_key),
"private",
),
publicKey: await importJwk(
JSON.parse(keys[keyType].public_key),
"public",
),
});
}
}
return pairs;
});
우선 가장 먼저 주목해야 할 것은 setActorDispatcher()
메서드에 연달아 호출되고 있는 setKeyPairsDispatcher()
메서드입니다. 이 메서드는 콜백 함수에서 반환된 키 쌍들을 계정에 연결하는 역할을 합니다. 이렇게 키 쌍들을 연결해야 Fedify가 액티비티를 발신할 때 자동으로 등록된 개인 키들로 디지털 서명을 추가합니다.
generateCryptoKeyPair()
함수는 새로운 개인 키 및 공개 키 쌍을 생성하여 CryptoKeyPair
객체로 반환합니다. 참고로 CryptoKeyPair
타입은 { privateKey: CryptoKey; publicKey: CryptoKey; }
형식입니다.
exportJwk()
함수는 CryptoKey
객체를 JWK 형식으로 표현한 객체를 반환합니다. JWK 형식이 무엇인지 알 필요는 없습니다. 그저 암호 키를 JSON으로 표현하는 표준적인 형식이라고 이해하시면 충분합니다. CryptoKey
는 암호 키를 JavaScript 객체로 표현하기 위한 웹 표준 타입입니다.
importJwk()
함수는 JWK 형식으로 표현된 키를 CryptoKey
객체로 변환합니다. exportJwk()
함수의 반대라고 이해하시면 됩니다.
자, 그럼 이제 다시 setActorDispatcher()
메서드로 눈을 돌립시다. getActorKeyPairs()
라는 메서드가 쓰이고 있는데, 이 메서드는 이름과 같이 액터의 키 쌍들을 반환합니다. 액터의 키 쌍들은 바로 앞에서 살펴본 setKeyPairsDispatcher()
메서드로 불러온 바로 그 키 쌍들입니다. 우리는 RSA-PKCS#1-v1.5와 Ed25519 형식으로 된 두 쌍의 키를 불러왔으므로, getActorKeyPairs()
메서드는 두 키 쌍의 배열을 반환합니다. 각 배열의 원소는 키 쌍을 여러 형식으로 표현한 객체인데, 다음과 같이 생겼습니다:
interface ActorKeyPair {
privateKey: CryptoKey; // 개인 키
publicKey: CryptoKey; // 공개 키
keyId: URL; // 키의 고유 식별 URI
cryptographicKey: CryptographicKey; // 공개 키의 다른 형식
multikey: Multikey; // 공개 키의 또 다른 형식
}
CryptoKey
와 CryptographicKey
와 Multikey
가 각각 어떻게 다른지, 왜 이렇게 여러 형식이 있어야 하는지는 이 자리에서 설명하기엔 복잡합니다. 다만 지금은 Person
객체를 초기화할 때 publicKey
속성은 CryptographicKey
형식을 받고 assertionMethods
속성은 MultiKey[]
(Multikey
의 배열을 TypeScript에서 이렇게 표기) 형식을 받는다는 것만 짚고 넘어가도록 합시다.
그나저나, Person
객체에는 왜 공개 키를 갖는 속성이 publicKey
와 assertionMethods
로 두 개나 있을까요? ActivityPub에는 원래 publicKey
속성만 있었지만, 나중에 여러 키를 등록할 수 있도록 assertionMethods
속성이 추가되었습니다. 앞서 RSA-PKCS#1-v1.5 형식과 Ed25519 형식의 키를 모두 생성했던 것과 비슷한 이유로, 여러 소프트웨어와의 호환성을 위해 두 속성 모두 설정하는 것입니다. 자세히 보면, 레거시 속성인 publicKey
에는 레거시 키 형식인 RSA-PKCS#1-v1.5 키만 등록하고 있다는 것을 알 수 있습니다 (배열의 첫 번째 항목에 RSA-PKCS#1-v1.5 키 쌍이, 두 번째 항목에 Ed25519 키 쌍이 들어감).
Tip
사실 publicKey
속성도 여러 키를 담을 수는 있습니다. 그렇지만 많은 소프트웨어들이 이미 publicKey
속성에는 단 하나의 키만 들어갈 것이라는 전제 하에 구현되었기 때문에 오작동할 때가 많습니다. 이를 피하기 위해 assertionMethods
라는 새로운 속성이 제안된 것입니다.
이에 관해 관심이 생기신 분들은 FEP-521a 문서를 참고하세요.
자, 액터 객체에 암호 키들을 등록했으므로 잘 동작하는지 확인하도록 합시다. 다음 명령으로 액터를 조회합니다.
fedify lookup http://localhost:8000/users/johndoe
정상적으로 동작한다면 아래와 같은 결과가 출력됩니다:
✔ Looking up the object...
Person {
id: URL "http://localhost:8000/users/johndoe",
name: "John Doe",
url: URL "http://localhost:8000/users/johndoe",
preferredUsername: "johndoe",
publicKey: CryptographicKey {
id: URL "http://localhost:8000/users/johndoe#main-key",
owner: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: {
name: "RSASSA-PKCS1-v1_5",
modulusLength: 4096,
publicExponent: Uint8Array(3) [ 1, 0, 1 ],
hash: { name: "SHA-256" }
},
usages: [ "verify" ]
}
},
assertionMethods: [
Multikey {
id: URL "http://localhost:8000/users/johndoe#main-key",
controller: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: {
name: "RSASSA-PKCS1-v1_5",
modulusLength: 4096,
publicExponent: Uint8Array(3) [ 1, 0, 1 ],
hash: { name: "SHA-256" }
},
usages: [ "verify" ]
}
},
Multikey {
id: URL "http://localhost:8000/users/johndoe#key-2",
controller: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: { name: "Ed25519" },
usages: [ "verify" ]
}
}
],
inbox: URL "http://localhost:8000/users/johndoe/inbox",
endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}
Person
객체의 publicKey
속성에는 RSA-PKCS#1-v1.5 형식의 CryptographicKey
객체 하나가, assertionMethods
속성에는 RSA-PKCS#1-v1.5 형식과 Ed25519 형식의 Multikey
객체가 둘 들어있는 것을 확인할 수 있습니다.
이제 실제 Mastodon에서 우리가 만든 액터를 볼 수 있는지 확인해 봅시다.
아쉽게도 현재 서버는 로컬에서만 접근이 가능합니다. 하지만 코드를 수정할 때마다 어딘가에 배포해서 테스트하는 것은 불편하겠죠. 배포하지 않고 바로 인터넷에 로컬 서버를 노출하여 테스트해 볼 수 있다면 얼마나 좋을까요?
여기, 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에 들어가 좌상단에 위치한 검색창에 붙여넣고 검색을 해 보세요:
위와 같이 검색 결과에 우리가 만든 액터가 보이면 정상입니다. 검색 결과에서 액터의 이름을 눌러서 프로필 페이지로 들어갈 수도 있습니다:
하지만 여기까지입니다. 아직 팔로는 할 수 없으니 시도하지 마세요! 다른 서버에서 우리가 만든 액터를 팔로할 수 있으려면, 수신함을 구현해야 합니다.
Note
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 },
);
}
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)
액티비티를 답장으로 보냅니다. 첫째 파라미터로 발신자, 둘째 파라미터로 수신자, 셋째 파라미터로 보낼 액티비티 객체를 받습니다.
자, 그럼 팔로 요청이 제대로 수신되는지 확인할 차례입니다.
보통의 Mastodon 서버에서 테스트를 해도 괜찮긴 하지만, 액티비티가 구체적으로 어떻게 오가는지 확인할 수 있는 ActivityPub.Academy 서버를 이용하도록 합니다. ActivityPub.Academy는 교육 및 디버깅 용도의 특수한 Mastodon 서버인데, 클릭 한 번으로 임시 계정을 쉽게 만들 수 있습니다.
개인 정보 보호 정책에 동의한 뒤 등록하기 버튼을 눌러 새 계정을 생성합니다. 생성된 계정은 무작위로 지어진 이름과 핸들을 갖게 되며, 하루가 지나면 알아서 사라집니다. 대신, 계정은 또 새로 생성할 수 있습니다.
로그인이 되고 나면 화면의 좌상단에 위치한 검색창에 우리가 만든 액터의 핸들을 붙여넣고 검색합니다:
우리가 만든 액터가 검색 결과에 표시되면, 오른쪽에 있는 팔로 버튼을 눌러서 팔로 요청을 보냅니다. 그리고 우측 메뉴에서 Activity Log를 누릅니다:
그럼 방금 팔로 버튼을 누름으로써 ActivityPub.Academy 서버에서 우리가 만든 액터의 수신함으로 Follow
액티비티가 전송되었다는 표시가 보입니다. 우하단의 show source를 누르면 액티비티의 내용까지 볼 수 있습니다:
액티비티가 잘 전송되었다는 걸 확인했으니, 실제로 저희가 짠 수신함 코드가 잘 동작했는지 확인할 차례입니다. 먼저 follows
테이블에 레코드가 잘 만들어졌는지 봅시다:
echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3
팔로 요청이 잘 처리되었다면, 다음과 같은 결과가 나옵니다 (물론, 시각은 다르겠죠?):
following_id |
follower_id |
created |
---|---|---|
1 |
2 |
2024-09-01 10:19:41 |
과연 actors
테이블에도 새 레코드가 생겼는지 확인합시다:
echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3
id |
user_id |
uri |
handle |
name |
inbox_url |
shared_inbox_url |
url |
created |
---|---|---|---|---|---|---|---|---|
2 |
https://activitypub.academy/users/dobussia_dovornath |
@dobussia_dovornath@activitypub.academy |
Dobussia Dovornath |
https://activitypub.academy/users/dobussia_dovornath/inbox |
https://activitypub.academy/inbox |
https://activitypub.academy/@dobussia_dovornath |
2024-09-01 10:19:41 |
다시, ActivityPub.Academy의 Activity Log를 봅시다. 우리가 만든 액터에서 보낸 Accept(Follow)
액티비티가 잘 도착했다면, 아래와 같이 표시될 것입니다:
자, 이렇게 여러분은 처음으로 ActivityPub을 통한 상호작용을 구현해냈습니다!
다른 서버의 액터가 우리가 만든 액터를 팔로했다가 다시 취소하면 어떻게 될까요? 한 번 ActivityPub.Academy에서 시험해 봅시다. 아까와 마찬가지로 ActivityPub.Academy 검색창에 우리가 만든 액터의 연합우주 핸들을 입력하여 검색합니다:
자세히 보면 액터 이름 오른쪽에 있던 팔로 버튼 자리에 언팔로(unfollow) 버튼이 있습니다. 이 버튼을 눌러서 팔로를 해제한 뒤, Activity Log에 들어가서 어떤 액티비티가 전송되나 확인해 봅시다:
위와 같이 Undo(Follow)
액티비티가 전송되었습니다. 우하단의 show source를 누르면 액티비티의 자세한 내용을 볼 수 있습니다:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo",
"type": "Undo",
"actor": "https://activitypub.academy/users/dobussia_dovornath",
"object": {
"id": "https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694",
"type": "Follow",
"actor": "https://activitypub.academy/users/dobussia_dovornath",
"object": "https://temp-address.serveo.net/users/johndoe"
}
}
위 JSON 객체를 보면 Undo(Follow)
액티비티 안에 아까 수신함으로 들어왔던 Follow
액티비티가 포함되어 있는 것을 볼 수 있습니다. 하지만 수신함에서 Undo(Follow)
액티비티를 수신했을 때의 동작을 아무 것도 정의하지 않았기에 아무 일도 일어나지 않았습니다.
Undo(Follow)
액티비티 수신팔로 취소를 구현하기 위해 src/federation.ts 파일을 열어 Fedify가 제공하는 Undo
클래스를 import
합니다:
import {
Accept,
Endpoints,
Follow,
Person,
Undo, // 추가됨
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
} from "@fedify/fedify";
그리고 on(Follow, ...)
뒤에 연달아 on(Undo, ...)
를 추가합니다:
federation
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
.on(Follow, async (ctx, follow) => {
// ... 생략됨 ...
})
.on(Undo, async (ctx, undo) => {
const object = await undo.getObject();
if (!(object instanceof Follow)) return;
if (undo.actorId == null || object.objectId == null) return;
const parsed = ctx.parseUri(object.objectId);
if (parsed == null || parsed.type !== "actor") return;
db.prepare(
`
DELETE FROM follows
WHERE following_id = (
SELECT actors.id
FROM actors
JOIN users ON actors.user_id = users.id
WHERE users.username = ?
) AND follower_id = (SELECT id FROM actors WHERE uri = ?)
`,
).run(parsed.identifier, undo.actorId.href);
});
이번에는 팔로 요청을 처리할 때보다 코드가 짧습니다. Undo(Follow)
액티비티 안에 든 게 Follow
액티비티인지 확인한 뒤, parseUri()
메서드를 이용해 취소하려는 Follow
액티비티의 팔로 대상이 우리가 만든 액터인지 확인하고, follows
테이블에서 해당하는 레코드를 삭제합니다.
아까 ActivityPub.Academy에서 이미 언팔로 버튼을 눌러버려서 한 번 더 언팔로를 할 수 없습니다. 어쩔 수 없이 다시 팔로한 뒤, 언팔로하여 테스트를 해야 합니다. 하지만 그에 앞서, follows
테이블을 비워 줄 필요가 있습니다. 그렇지 않으면 팔로 요청이 왔을 때 이미 레코드가 존재하므로 오류가 날 것입니다.
sqlite3
명령어를 이용해 follows
테이블을 비웁시다:
echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3
그리고 다시 팔로 버튼을 누른 뒤, 데이터베이스를 확인합니다:
echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3
팔로 요청이 잘 처리되었다면, 다음과 같은 결과가 나옵니다:
following_id |
follower_id |
created |
---|---|---|
1 |
2 |
2024-09-02 01:05:17 |
그리고 다시 언팔로 버튼을 누른 뒤, 데이터베이스를 한 번 더 확인합니다:
echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3
언팔로 요청이 잘 처리되었다면, 레코드가 사라졌으므로 다음과 같은 결과가 나옵니다:
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> ·{" "}
<a href={`/users/${username}/followers`}>
{followers === 1 ? "1 follower" : `${followers} followers`}
</a>
</p>
</hgroup>
</>
);
ProfileProps
에는 두 개의 프롭이 추가되었습니다. followers
는 말 그대로 팔로워 수를 담는 프롭입니다. username
은 팔로워 목록으로 링크를 걸기 위해 URL에 들어갈 아이디를 받습니다.
그러면 다시 src/app.tsx 파일로 돌아가, GET /users/{username}
요청 핸들러를 다음과 같이 수정합니다:
app.get("/users/:username", async (c) => {
// ... 생략 ...
if (user == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 언제나 하나의 레코드를 반환
const { followers } = db
.prepare<unknown[], { followers: number }>(
`
SELECT count(*) AS followers
FROM follows
JOIN actors ON follows.following_id = actors.id
WHERE actors.user_id = ?
`,
)
.get(user.id)!;
// ... 생략 ...
return c.html(
<Layout>
<Profile
name={user.name ?? user.username}
username={user.username}
handle={handle}
followers={followers}
/>
</Layout>,
);
});
데이터베이스 안의 follows
테이블의 레코드 수를 세는 SQL이 추가되었군요. 자, 그럼 이제 바뀐 프로필 페이지를 확인해 봅시다. 웹 브라우저에서 http://localhost:8000/users/johndoe 페이지를 열면 아래와 같이 보일 것입니다:
그런데 한 가지 문제가 있습니다. ActivityPub.Academy가 아닌 다른 Mastodon 서버에서 우리가 만든 액터를 조회해봅시다. (조회하는 법은 이제 다 아시죠? 공개 인터넷에 노출된 상태에서, 액터 핸들을 Mastodon 검색창에 치면 됩니다.) Mastodon에서 우리가 만든 액터의 프로필을 보면 아마도 이상한 점을 눈치 챌 수 있을 것입니다:
바로 팔로워 수가 0으로 나온다는 것입니다. 이는 우리가 만든 액터가 팔로워 목록을 ActivityPub을 통해 노출하고 있지 않기 때문입니다. ActivityPub에서 팔로워 목록을 노출하려면 팔로워 컬렉션을 정의해야 합니다.
src/federation.ts 파일을 열어 Fedify가 제공하는 Recipient
타입을 import
합니다:
import {
Accept,
Endpoints,
Follow,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
type Recipient, // 추가됨
} from "@fedify/fedify";
그리고 아래쪽에 팔로워 컬렉션 디스패처를 추가합니다:
federation
.setFollowersDispatcher(
"/users/{identifier}/followers",
(ctx, identifier, cursor) => {
const followers = db
.prepare<unknown[], Actor>(
`
SELECT followers.*
FROM follows
JOIN actors AS followers ON follows.follower_id = followers.id
JOIN actors AS following ON follows.following_id = following.id
JOIN users ON users.id = following.user_id
WHERE users.username = ?
ORDER BY follows.created DESC
`,
)
.all(identifier);
const items: Recipient[] = followers.map((f) => ({
id: new URL(f.uri),
inboxId: new URL(f.inbox_url),
endpoints:
f.shared_inbox_url == null
? null
: { sharedInbox: new URL(f.shared_inbox_url) },
}));
return { items };
},
)
.setCounter((ctx, identifier) => {
const result = db
.prepare<unknown[], { cnt: number }>(
`
SELECT count(*) AS cnt
FROM follows
JOIN actors ON actors.id = follows.following_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ?
`,
)
.get(identifier);
return result == null ? 0 : result.cnt;
});
setFollowersDispatcher()
메서드에서는 GET /users/{identifier}/followers
요청이 왔을 때 응답할 팔로워 컬렉션 객체를 만듭니다. SQL이 조금 길긴 하지만 정리하자면 identifier
파라미터로 들어온 아이디를 팔로하는 액터의 목록을 구하는 것입니다. items
에는 Recipient
객체들을 담는데, Recipient
타입은 다음과 같이 생겼습니다:
export interface Recipient {
readonly id: URL | null;
readonly inboxId: URL | null;
readonly endpoints?: {
sharedInbox: URL | null;
} | null;
}
id
속성에는 액터의 고유 IRI가 들어가고, inboxId
에는 액터의 개인 수신함 URL이 들어갑니다. endpoints.sharedInbox
에는 액터의 공유 수신함 URL이 들어갑니다. 우리는 actors
테이블에 그 모든 정보를 다 담고 있으니, 해당 정보들로 items
배열을 채워줄 수 있습니다.
setCounter()
메서드에서는 팔로워 컬렉션의 전체 수량을 구합니다. 여기서도 SQL이 조금 복잡하긴 하지만 요약하면 identifier
파라미터로 들어온 아이디를 팔로하는 액터의 수를 구하는 것입니다.
그럼 팔로워 컬렉션이 잘 동작하는지 확인하기 위해, fedify lookup
명령을 사용합시다:
fedify lookup http://localhost:8000/users/johndoe/followers
제대로 구현되었다면 아래와 같은 결과가 나올 것입니다:
✔ Looking up the object...
OrderedCollection {
totalItems: 1,
items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ]
}
그런데, 이렇게 팔로워 컬렉션을 만들어 놓기만 해서는 다른 서버에서 팔로워 컬렉션이 어디 있는지 알 수 없습니다. 그래서 액터 디스패처에서 팔로워 컬렉션에 링크를 걸어 줘야 합니다:
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
// ... 생략 ...
return new Person({
// ... 생략 ...
followers: ctx.getFollowersUri(identifier),
});
})
액터도 fedify lookup
으로 조회하여 봅시다:
fedify lookup http://localhost:8000/users/johndoe
아래와 같이 결과에 "followers"
속성이 포함되어 있으면 됩니다:
✔ Looking up the object...
Person {
... 생략 ...
inbox: URL "http://localhost:8000/users/johndoe/inbox",
followers: URL "http://localhost:8000/users/johndoe/followers",
endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}
그럼 이제 다시 Mastodon에서 우리가 만든 액터를 조회해 볼까요? 하지만 그 결과는 좀 실망스러울 수 있습니다:
팔로워 수는 여전히 0으로 나오기 때문이죠. 이는 Mastodon이 다른 서버의 액터 정보를 캐시(cache)하고 있기 때문입니다. 이를 업데이트하는 방법이 있긴 하지만 F5 키를 누르는 것처럼 쉽지는 않습니다:
Update
액티비티를 날리는 것인데, 귀찮은 코딩을 필요로 합니다.fedify tunnel
을 껐다 켜서 새로운 임시 도메인을 할당 받는 것입니다.여러분이 다른 Mastodon 서버에서 정확한 팔로워 수가 표시되는 것을 직접 확인하고 싶으시다면 제가 나열한 방법들 중 하나를 시도해 보시기 바랍니다.
자, 이제 드디어 게시물을 구현할 때가 왔습니다. 일반적인 블로그와 달리 우리가 만들 마이크로블로그는 다른 서버에서 작성된 게시물도 저장할 수 있어야 합니다. 이를 염두에 두고 설계해 봅시다.
바로 posts
테이블부터 만듭시다. src/schema.sql 파일을 열어 아래 SQL을 추가합니다:
CREATE TABLE IF NOT EXISTS posts (
id INTEGER NOT NULL PRIMARY KEY,
uri TEXT NOT NULL UNIQUE CHECK (uri <> ''),
actor_id INTEGER NOT NULL REFERENCES actors (id),
content TEXT NOT NULL,
url TEXT CHECK (url LIKE 'https://%' OR url LIKE 'http://%'),
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '')
);
id
칼럼은 테이블의 기본 키입니다.uri
칼럼은 게시물의 고유 URI를 담습니다. 앞서 말했다시피 ActivityPub 객체는 모두 고유한 URI를 가져야 하기 때문입니다.actor_id
칼럼은 게시물을 작성한 액터를 가리킵니다.content
칼럼에는 게시물 내용을 담습니다.url
칼럼에는 웹 브라우저에서 게시물을 표시하는 URL을 담습니다. ActivityPub 객체의 URI와 웹 브라우저에 표시되는 페이지의 URL이 일치하는 경우도 있지만, 그렇지 않은 경우도 있기 때문에 별도 칼럼이 필요합니다. 하지만 비어 있을 수도 있습니다.created
칼럼에는 게시물 작성 시각을 담습니다.SQL을 실행하여 posts
테이블을 생성합시다:
sqlite3 microblog.sqlite3 < src/schema.sql
posts
테이블에 저장될 레코드를 JavaScript로 표현하는 Post
타입도 src/schema.ts 파일에 정의합니다:
export interface Post {
id: number;
uri: string;
actor_id: number;
content: string;
url: string | null;
created: string;
}
게시물을 작성하려면 양식이 어딘가에 있어야겠죠? 그러고 보니, 아직까지 첫 페이지도 제대로 만들지 않았습니다. 첫 페이지에 게시물 작성 양식을 추가하겠습니다.
먼저 src/views.tsx 파일을 열어 User
타입을 import
합니다:
import type { Actor, User } from "./schema.ts";
그리고 <Home>
컴포넌트를 정의합니다:
export interface HomeProps {
user: User & Actor;
}
export const Home: FC<HomeProps> = ({ user }) => (
<>
<hgroup>
<h1>{user.name}'s microblog</h1>
<p>
<a href={`/users/${user.username}`}>{user.name}'s profile</a>
</p>
</hgroup>
<form method="post" action={`/users/${user.username}/posts`}>
<fieldset>
<label>
<textarea name="content" required={true} placeholder="What's up?" />
</label>
</fieldset>
<input type="submit" value="Post" />
</form>
</>
);
그 다음에는 src/app.tsx 파일을 열어 앞서 정의한 <Home>
컴포넌트를 import
합니다:
import { FollowerList, Home, Layout, Profile, SetupForm } from "./views.tsx";
그리고 이미 있는 GET /
요청 핸들러를:
app.get("/", (c) => c.text("Hello, Fedify!"));
아래와 같이 고쳐줍니다:
app.get("/", (c) => {
const user = db
.prepare<unknown[], User & Actor>(
`
SELECT users.*, actors.*
FROM users
JOIN actors ON users.id = actors.user_id
LIMIT 1
`,
)
.get();
if (user == null) return c.redirect("/setup");
return c.html(
<Layout>
<Home user={user} />
</Layout>,
);
});
여기까지 했다면, 한 번 첫 페이지가 잘 나오나 확인합시다. 웹 브라우저에서 http://localhost:8000/ 페이지를 열면 아래와 같이 보여야 합니다:
게시물 작성 양식을 만들었으니, 실제로 게시물 내용을 posts
테이블에 저장하는 코드가 필요합니다.
먼저 src/federation.ts 파일을 열어 Fedify가 제공하는 Note
클래스를 import
합니다:
import {
Accept,
Endpoints,
Follow,
Note, // 추가됨
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
type Recipient,
} from "@fedify/fedify";
아래 코드를 추가합니다:
federation.setObjectDispatcher(
Note,
"/users/{identifier}/posts/{id}",
(ctx, values) => {
return null;
},
);
위 코드는 아직 별 역할을 하진 않지만, 게시물의 퍼머링크 형식을 정하는 데에 필요합니다. 실제 구현은 나중에 하도록 하겠습니다.
ActivityPub에서는 게시물의 내용을 HTML 형식으로 주고받습니다. 따라서 평문 형식으로 입력 받은 내용을 HTML 형식으로 변환해야 합니다. 이 때, <
, >
와 같은 문자들을 HTML에서 표시할 수 있도록 <
, >
와 같은 HTML 엔티티로 변환해주는 stringify-entities 패키지가 필요합니다:
npm add stringify-entities
그리고 src/app.tsx 파일을 열어 설치한 패키지를 import
합니다.
import { stringifyEntities } from "stringify-entities";
Post
타입과 Fedify가 제공하는 Note
클래스도 import
합니다:
import type { Actor, Post, User } from "./schema.ts";
import { Note } from "@fedify/fedify";
그리고 POST /users/{username}/posts
요청 핸들러를 구현합니다:
app.post("/users/:username/posts", async (c) => {
const username = c.req.param("username");
const actor = db
.prepare<unknown[], Actor>(
`
SELECT actors.*
FROM actors
JOIN users ON users.id = actors.user_id
WHERE users.username = ?
`,
)
.get(username);
if (actor == null) return c.redirect("/setup");
const form = await c.req.formData();
const content = form.get("content")?.toString();
if (content == null || content.trim() === "") {
return c.text("Content is required", 400);
}
const ctx = fedi.createContext(c.req.raw, undefined);
const url: string | null = db.transaction(() => {
const post = db
.prepare<unknown[], Post>(
`
INSERT INTO posts (uri, actor_id, content)
VALUES ('https://localhost/', ?, ?)
RETURNING *
`,
)
.get(actor.id, stringifyEntities(content, { escapeOnly: true }));
if (post == null) return null;
const url = ctx.getObjectUri(Note, {
identifier: username,
id: post.id.toString(),
}).href;
db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
url,
url,
post.id,
);
return url;
})();
if (url == null) return c.text("Failed to create post", 500);
return c.redirect(url);
});
평범하게 posts
테이블에 레코드를 추가하는 코드이긴 하지만 한 가지 특이한 부분이 있습니다. 게시물을 표현하는 ActivityPub 객체의 URI를 구하려면 posts.id
가 먼저 결정되어 있어야 하기 때문에, posts.uri
칼럼에 https://localhost/
라는 임시 URI를 먼저 집어 넣어 레코드를 추가한 뒤, 결정된 posts.id
를 기반으로 getObjectUri()
메서드를 사용하여 실제 URI를 구해서 레코드를 갱신하게 되어 있습니다.
그럼 이제 웹 브라우저에서 http://localhost:8000/ 페이지를 연 뒤, 게시물을 작성해 봅시다:
Post 버튼을 눌러 게시물을 작성하면, 안타깝게도 404 Not Found
오류가 납니다:
왜냐하면 게시물 퍼머링크로 리다이렉트하도록 구현했는데, 아직 게시물 페이지를 구현하지 않았기 때문입니다. 하지만, 그래도 posts
테이블에는 레코드가 만들어졌을 것입니다. 한 번 확인해 봅시다:
echo "SELECT * FROM posts;" | sqlite3 -table microblog.sqlite3
그럼 다음과 같은 결과나 나올 것입니다:
id |
uri |
actor_id |
content |
url |
created |
---|---|---|---|---|---|
1 |
http://localhost:8000/users/johndoe/posts/1 |
1 |
It's my first post! |
http://localhost:8000/users/johndoe/posts/1 |
2024-09-02 08:10:55 |
게시물 작성 후 404 Not Found
오류가 나지 않도록, 게시물 페이지를 구현합시다.
src/views.tsx 파일을 열어 Post
타입을 import
합니다:
import type { Actor, Post, User } from "./schema.ts";
그리고 <PostPage>
컴포넌트 및 <PostView>
컴포넌트를 정의합니다:
export interface PostPageProps extends ProfileProps, PostViewProps {}
export const PostPage: FC<PostPageProps> = (props) => (
<>
<Profile
name={props.name}
username={props.username}
handle={props.handle}
followers={props.followers}
/>
<PostView post={props.post} />
</>
);
export interface PostViewProps {
post: Post & Actor;
}
export const PostView: FC<PostViewProps> = ({ post }) => (
<article>
<header>
<ActorLink actor={post} />
</header>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<footer>
<a href={post.url ?? post.uri}>
<time datetime={new Date(post.created).toISOString()}>
{post.created}
</time>
</a>
</footer>
</article>
);
이제 데이터베이스에서 게시물 데이터를 불러와 <PostPage>
컴포넌트로 렌더링합시다. src/app.tsx 파일을 열고 앞서 정의한 <PostPage>
컴포넌트를 import
합니다:
import {
FollowerList,
Home,
Layout,
PostPage, // 추가됨
Profile,
SetupForm,
} from "./views.tsx";
그리고 GET /users/{username}/posts/{id}
요청 핸들러를 구현합니다:
app.get("/users/:username/posts/:id", (c) => {
const post = db
.prepare<unknown[], Post & Actor & User>(
`
SELECT users.*, actors.*, posts.*
FROM posts
JOIN actors ON actors.id = posts.actor_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ? AND posts.id = ?
`,
)
.get(c.req.param("username"), c.req.param("id"));
if (post == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 언제나 하나의 레코드를 반환
const { followers } = db
.prepare<unknown[], { followers: number }>(
`
SELECT count(*) AS followers
FROM follows
WHERE follows.following_id = ?
`,
)
.get(post.actor_id)!;
return c.html(
<Layout>
<PostPage
name={post.name ?? post.username}
username={post.username}
handle={post.handle}
followers={followers}
post={post}
/>
</Layout>,
);
});
그럼 아까 404 Not Found
오류가 났던 http://localhost:8000/users/johndoe/posts/1 페이지를 웹 브라우저에서 열어 봅시다:
Note
객체 디스패처그럼 이제 게시물을 다른 Mastodon 서버에서 조회할 수 있나 확인해 볼까요? 먼저 fedify tunnel
을 이용하여 로컬 서버를 공개 인터넷에 노출합니다.
그 상태에서, Mastodon 검색창에 글의 퍼머링크인 https://temp-address.serveo.net/users/johndoe/posts/1(여러분의 임시 도메인 이름으로 치환하세요)을 쳐봅시다:
안타깝게도 검색 결과는 비어 있습니다. 게시물을 ActivityPub 객체 형식으로 노출하지 않았기 때문입니다. 그럼 게시물을 ActivityPub 객체로 노출해 봅시다.
구현에 앞서 필요한 라이브러리를 설치해야 합니다. Fedify에서 시각을 표현하는 데에 쓰는 Temporal API가 아직 Node.js에 내장되어 있지 않기 때문에 이를 폴리필(polyfill)해주는 @js-temporal/polyfill 패키지가 필요합니다:
npm add @js-temporal/polyfill
src/federation.ts 파일을 열어 설치한 패키지를 import
합니다:
import { Temporal } from "@js-temporal/polyfill";
Post
타입과 Fedify가 제공하는 PUBLIC_COLLECTION
상수도 import
합니다.
import {
Accept,
Endpoints,
Follow,
Note,
PUBLIC_COLLECTION, // 추가됨
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
type Recipient,
} from "@fedify/fedify";
import type {
Actor,
Key,
Post, // 추가됨
User,
} from "./schema.ts";
마이크로블로그의 게시물처럼 짧은 글은 ActivityPub에서 보통 Note
로 표현됩니다. Note
클래스에 대한 객체 디스패처는 이미 빈 구현이나마 만들어 두었었죠:
federation.setObjectDispatcher(
Note,
"/users/{identifier}/posts/{id}",
(ctx, values) => {
return null;
},
);
이를 아래와 같이 고칩니다:
federation.setObjectDispatcher(
Note,
"/users/{identifier}/posts/{id}",
(ctx, values) => {
const post = db
.prepare<unknown[], Post>(
`
SELECT posts.*
FROM posts
JOIN actors ON actors.id = posts.actor_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ? AND posts.id = ?
`,
)
.get(values.identifier, values.id);
if (post == null) return null;
return new Note({
id: ctx.getObjectUri(Note, values),
attribution: ctx.getActorUri(values.identifier),
to: PUBLIC_COLLECTION,
cc: ctx.getFollowersUri(values.identifier),
content: post.content,
mediaType: "text/html",
published: Temporal.Instant.from(`${post.created.replace(" ", "T")}Z`),
url: ctx.getObjectUri(Note, values),
});
},
);
Note
객체를 생성할 때 채워지는 속성 값들은 다음과 같은 역할을 합니다:
attribution
속성에 ctx.getActorUri(values.identifier)
을 넣는 것은 이 게시물의 작성자가 우리가 만든 액터라는 것을 나타냅니다.to
속성에 PUBLIC_COLLECTION
을 넣는 것은 이 게시물이 전체 공개 게시물이라는 것을 나타냅니다.cc
속성에 ctx.getFollowersUri(values.identifier)
을 넣는 것은 이 게시물이 팔로워들에게 전달된다는 것을 나타내지만, 이 자체로는 큰 의미는 없습니다.그러면 다시 한 번 Mastodon 검색창에 게시물의 퍼머링크인 https://temp-address.serveo.net/users/johndoe/posts/1(여러분의 임시 도메인 이름으로 치환하세요)을 쳐봅시다:
이번에는 검색 결과에 제대로 우리가 작성한 게시물이 나오네요!
Create(Note)
액티비티 발신하지만 Mastodon에서 우리가 만든 액터를 팔로 해도, 새로 작성한 게시물이 Mastodon 타임라인에 올라오지는 않습니다. 왜냐하면 Mastodon이 새 게시물을 알아서 받아가는 게 아니라, 새 게시물을 작성한 쪽에서 Create(Note)
액티비티를 전송하여 새 게시물이 만들어졌다는 것을 알려줘야 하기 때문입니다.
게시물 생성시에 Create(Note)
액티비티를 전송하도록 코드를 고쳐봅시다. src/app.tsx 파일을 열어 Fedify가 제공하는 Create
클래스를 import
합니다:
import { Create, Note } from "@fedify/fedify";
그리고 POST /users/{username}/posts
요청 핸들러를 다음과 같이 수정합니다:
app.post("/users/:username/posts", async (c) => {
// ... 생략 ...
const ctx = fedi.createContext(c.req.raw, undefined);
const post: Post | null = db.transaction(() => {
const post = db
.prepare<unknown[], Post>(
`
INSERT INTO posts (uri, actor_id, content)
VALUES ('https://localhost/', ?, ?)
RETURNING *
`,
)
.get(actor.id, stringifyEntities(content, { escapeOnly: true }));
if (post == null) return null;
const url = ctx.getObjectUri(Note, {
identifier: username,
id: post.id.toString(),
}).href;
db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
url,
url,
post.id,
);
return post;
})();
if (post == null) return c.text("Failed to create post", 500);
const noteArgs = { identifier: username, id: post.id.toString() };
const note = await ctx.getObject(Note, noteArgs);
await ctx.sendActivity(
{ identifier: username },
"followers",
new Create({
id: new URL("#activity", note?.id ?? undefined),
object: note,
actors: note?.attributionIds,
tos: note?.toIds,
ccs: note?.ccIds,
}),
);
return c.redirect(ctx.getObjectUri(Note, noteArgs).href);
});
getObject()
메서드는 객체 디스패처가 만들어 주는 ActivityPub 객체를 반환합니다. 여기서는 Note
객체를 반환하겠죠. 그 Note
객체를 Create
객체를 생성할 때 object
속성에 넣습니다. 액티비티의 수신자를 나타내는 tos
(to
의 복수형) 및 ccs
(cc
의 복수형) 속성은 Note
객체와 동일하게 설정합니다. 액티비티의 id
는 임의의 고유한 URI를 지어내서 설정합니다.
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를 살펴봅시다:
잘 들어왔네요. 그럼 ActivityPub.Academy에서 타임라인을 살펴봅시다:
해냈습니다!
현재 프로필 페이지에는 이름과 연합우주 핸들, 팔로워 수만 나올 뿐 정작 게시물은 보이지 않습니다. 프로필 페이지에서 작성한 게시물을 보여줍시다.
src/views.tsx 파일을 열고 <PostList>
컴포넌트를 추가합니다:
export interface PostListProps {
posts: (Post & Actor)[];
}
export const PostList: FC<PostListProps> = ({ posts }) => (
<>
{posts.map((post) => (
<div key={post.id}>
<PostView post={post} />
</div>
))}
</>
);
그리고 src/app.tsx 파일을 열고, 방금 정의한 <PostList>
컴포넌트를 import
합니다:
import {
FollowerList,
Home,
Layout,
PostList, // 추가됨
PostPage,
Profile,
SetupForm,
} from "./views.tsx";
이미 있는 GET /users/{username}
요청 핸들러를 다음과 같이 변경합니다:
app.get("/users/:username", async (c) => {
// ... 생략 ...
const posts = db
.prepare<unknown[], Post & Actor>(
`
SELECT actors.*, posts.*
FROM posts
JOIN actors ON posts.actor_id = actors.id
WHERE actors.user_id = ?
ORDER BY posts.created DESC
`,
)
.all(user.user_id);
// ... 생략 ...
return c.html(
<Layout>
// ... 생략 ...
<PostList posts={posts} />
</Layout>,
);
});
그럼 이제 웹 브라우저에서 http://localhost:8000/users/johndoe 페이지를 열어봅시다:
작성한 게시물들이 잘 나오는 것을 볼 수 있습니다.
현재 우리가 만든 액터는 다른 서버의 액터로부터 팔로 요청을 받을 수는 있지만, 다른 서버의 액터에게 팔로 요청을 보낼 수는 없습니다. 팔로를 할 수 없으니 다른 액터가 작성한 게시물도 볼 수 없습니다. 자, 그럼 다른 서버의 액터에 팔로 요청을 보내는 기능을 추가합시다.
UI 먼저 만듭시다. src/views.tsx 파일을 열고, 이미 있는 <Home>
컴포넌트를 다음과 같이 수정합니다:
export const Home: FC<HomeProps> = ({ user }) => (
<>
<hgroup>
{/* ... 생략 ... */}
</hgroup>
<form method="post" action={`/users/${user.username}/following`}>
{/* biome-ignore lint/a11y/noRedundantRoles: PicoCSS가 role=group을 요구함 */}
<fieldset role="group">
<input
type="text"
name="actor"
required={true}
placeholder="Enter an actor handle (e.g., @johndoe@mastodon.com) or URI (e.g., https://mastodon.com/@johndoe)"
/>
<input type="submit" value="Follow" />
</fieldset>
</form>
<form method="post" action={`/users/${user.username}/posts`}>
{/* ... 생략 ... */}
</form>
</>
);
첫 페이지가 잘 수정되었는지 확인하기 위해 웹 브라우저에서 http://localhost:8000/ 페이지를 열어 봅시다:
Follow
액티비티 전송팔로 요청 UI가 생겼으니 실제로 Follow
액티비티를 전송하는 코드를 짤 차례입니다.
src/app.tsx 파일을 열고 Fedify가 제공하는 Follow
클래스와 isActor()
및 lookupObject()
함수를 import
합니다:
import {
Create,
Follow, // 추가됨
isActor, // 추가됨
lookupObject, // 추가됨
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 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/(도메인 이름은 치환하세요) 페이지를 들어갑니다:
팔로 요청 입력창에 팔로할 액터의 연합우주 핸들을 입력해야 합니다. 여기서는 쉬운 디버깅을 위해 ActivityPub.Academy의 액터를 입력하도록 합시다. 참고로, ActivityPub.Academy에서 로그인 된 임시 계정의 핸들은 임시 계정의 이름을 클릭하여 프로필 페이지에 들어가면 이름 바로 아래에서 볼 수 있습니다:
다음과 같이 ActivityPub.Academy의 액터 핸들을 입력한 뒤, Follow 버튼을 눌러 팔로 요청을 보냅니다:
그리고 ActivityPub.Academy의 Activity Log를 확인합니다:
Activity Log에는 우리가 전송한 Follow
액티비티와, ActivityPub.Academy로부터 전송된 답장인 Accept(Follow)
액티비티가 표시됩니다.
ActivityPub.Academy의 알림 페이지로 가면 실제로 팔로 요청이 도착한 것을 확인할 수 있습니다:
Accept(Follow)
액티비티 수신하지만 아직 수신된 Accept(Follow)
액티비티에 대해 아무런 행동도 취하고 있지 않기 때문에, 이 부분을 구현해야 합니다.
src/federation.ts 파일을 열어 Fedify에서 제공하는 isActor()
함수 및 Actor
타입을 import
합니다:
import {
Accept,
Endpoints,
Follow,
Note,
PUBLIC_COLLECTION,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
isActor, // 추가됨
type Actor as APActor, // 추가됨
type Recipient,
} from "@fedify/fedify";
이 소스 파일 안에서 Actor
타입의 이름이 겹치므로 APActor
라는 별명을 지어줬습니다.
구현에 앞서, 처음 마주한 액터 정보를 actors
테이블에 넣는 코드를 리팩터링하여 재사용 가능하게 바꿔봅시다. 아래 함수를 추가합니다:
async function persistActor(actor: APActor): Promise<Actor | null> {
if (actor.id == null || actor.inboxId == null) {
logger.debug("Actor is missing required fields: {actor}", { actor });
return null;
}
return (
db
.prepare<unknown[], Actor>(
`
-- 액터 레코드를 새로 추가하거나 이미 있으면 갱신
INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (uri) DO UPDATE SET
handle = excluded.handle,
name = excluded.name,
inbox_url = excluded.inbox_url,
shared_inbox_url = excluded.shared_inbox_url,
url = excluded.url
WHERE
actors.uri = excluded.uri
RETURNING *
`,
)
.get(
actor.id.href,
await getActorHandle(actor),
actor.name?.toString(),
actor.inboxId.href,
actor.endpoints?.sharedInbox?.href,
actor.url?.href,
) ?? null
);
}
정의한 persistActor()
함수는 인자로 들어온 액터 객체에 해당하는 레코드를 actors
테이블에 추가합니다. 이미 테이블에 해당하는 레코드가 있다면, 레코드를 갱신합니다.
수신함의 on(Follow, ...)
부분에서 같은 역할을 하는 코드를 persistActor()
함수를 쓰게 바꿉니다:
federation
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
.on(Follow, async (ctx, follow) => {
// ... 생략 ...
if (followingId == null) {
logger.debug(
"Failed to find the actor to follow in the database: {object}",
{ object },
);
}
const followerId = (await persistActor(follower))?.id;
db.prepare(
"INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
).run(followingId, followerId);
// ... 생략 ...
})
리팩터링을 끝냈으니 수신함에 Accept(Follow)
액티비티를 받았을 때 취할 행동을 구현합니다:
.on(Accept, async (ctx, accept) => {
const follow = await accept.getObject();
if (!(follow instanceof Follow)) return;
const following = await accept.getActor();
if (!isActor(following)) return;
const follower = follow.actorId;
if (follower == null) return;
const parsed = ctx.parseUri(follower);
if (parsed == null || parsed.type !== "actor") return;
const followingId = (await persistActor(following))?.id;
if (followingId == null) return;
db.prepare(
`
INSERT INTO follows (following_id, follower_id)
VALUES (
?,
(
SELECT actors.id
FROM actors
JOIN users ON actors.user_id = users.id
WHERE users.username = ?
)
)
`,
).run(followingId, parsed.identifier);
});
유효성을 검사하는 코드가 길지만 요약하면 Accept(Follow)
액티비티의 내용으로부터 팔로 요청을 보낸 액터(follower
)와 팔로 요청을 받은 액터(following
)를 구하고 follows
테이블에 레코드를 추가하는 것입니다.
이제 잘 동작하는지 확인해야 하는데, 문제가 있습니다. 아까 팔로 요청을 보냈을 때 ActivityPub.Academy 쪽에서는 팔로 요청을 수락하고 Accept(Follow)
액티비티를 이미 보냈기 때문에, 이 상태에서 다시 한 번 팔로 요청을 보내도 무시하게 됩니다. 따라서, ActivityPub.Academy에서 로그아웃을 한 뒤 다시 임시 계정을 만들어서 테스트를 해야 합니다.
ActivityPub.Academy에서 새 임시 계정을 만들었다면, fedify tunnel
명령으로 로컬 서버를 공개 인터넷에 노출한 상태에서, 웹 브라우저에서 https://temp-address.serveo.net/(도메인 이름은 치환하세요) 페이지를 들어가 ActivityPub.Academy의 새 임시 계정에 팔로 요청을 보냅니다.
팔로 요청이 잘 전송되었다면, 아까와 마찬가지로 Activity Log에 Follow
액티비티가 도착한 후 답장으로 Accept(Follow)
액티비티가 발신된 것을 볼 수 있을 것입니다:
아직은 팔로잉 목록을 구현하지 않았으므로, follows
테이블에 레코드가 제대로 들어갔나 직접 확인을 해 봅시다:
echo "SELECT * FROM follows WHERE follower_id = 1;" | sqlite3 -table microblog.sqlite3
성공했다면 다음과 같은 결과가 나올 것입니다 (following_id
칼럼에 든 값은 다소 다를 수 있습니다):
following_id |
follower_id |
created |
---|---|---|
3 |
1 |
2024-09-02 14:11:17 |
우리가 만든 액터가 팔로하는 액터의 목록을 표시하는 페이지를 만들어 봅시다.
먼저 src/views.tsx 파일을 열어 <FollowingList>
컴포넌트를 추가합니다:
export interface FollowingListProps {
following: Actor[];
}
export const FollowingList: FC<FollowingListProps> = ({ following }) => (
<>
<h2>Following</h2>
<ul>
{following.map((actor) => (
<li key={actor.id}>
<ActorLink actor={actor} />
</li>
))}
</ul>
</>
);
그 다음, src/app.tsx 파일을 열어 앞서 정의한 <FollowingList>
컴포넌트를 import
합니다:
import {
FollowerList,
FollowingList, // 추가됨
Home,
Layout,
PostList,
PostPage,
Profile,
SetupForm,
} from "./views.tsx";
그리고 GET /users/{username}/following
요청에 대한 핸들러를 추가합니다:
app.get("/users/:username/following", async (c) => {
const following = db
.prepare<unknown[], Actor>(
`
SELECT following.*
FROM follows
JOIN actors AS followers ON follows.follower_id = followers.id
JOIN actors AS following ON follows.following_id = following.id
JOIN users ON users.id = followers.user_id
WHERE users.username = ?
ORDER BY follows.created DESC
`,
)
.all(c.req.param("username"));
return c.html(
<Layout>
<FollowingList following={following} />
</Layout>,
);
});
제대로 구현되었는지 확인하기 위해 웹 브라우저에서 http://localhost:8000/users/johndoe/following 페이지를 열어봅시다:
팔로워 수를 보여주고 있는 것처럼, 팔로잉 수도 표시해야 합니다.
src/views.tsx 파일을 열어 <Profile>
컴포넌트를 다음과 같이 수정합니다:
export interface ProfileProps {
name: string;
username: string;
handle: string;
following: number; // 추가됨
followers: number;
}
export const Profile: FC<ProfileProps> = ({
name,
username,
handle,
following, // 추가됨
followers,
}) => (
<>
<hgroup>
<h1>
<a href={`/users/${username}`}>{name}</a>
</h1>
<p>
<span style="user-select: all;">{handle}</span> ·{" "}
<a href={`/users/${username}/following`}>{following} following</a>{" "}
·{" "}
<a href={`/users/${username}/followers`}>
{followers === 1 ? "1 follower" : `${followers} followers`}
</a>
</p>
</hgroup>
</>
);
<PostPage>
컴포넌트도 다음과 같이 수정합니다:
export interface PostPageProps extends ProfileProps, PostViewProps {}
export const PostPage: FC<PostPageProps> = (props) => (
<>
<Profile
name={props.name}
username={props.username}
handle={props.handle}
following={props.following}
followers={props.followers}
/>
<PostView post={props.post} />
</>
);
그럼 이제 실제로 데이터베이스를 조회하여 팔로잉 수를 구하는 코드를 짜야 합니다.
src/app.tsx 파일을 열어 GET /users/{username}
요청에 대한 핸들러를 다음과 같이 수정합니다:
app.get("/users/:username", async (c) => {
// ... 생략 ...
if (user == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 언제나 하나의 레코드를 반환
const { following } = db
.prepare<unknown[], { following: number }>(
`
SELECT count(*) AS following
FROM follows
JOIN actors ON follows.follower_id = actors.id
WHERE actors.user_id = ?
`,
)
.get(user.id)!;
// ... 생략 ...
return c.html(
<Layout>
<Profile
name={user.name ?? user.username}
username={user.username}
handle={handle}
following={following}
followers={followers}
/>
<PostList posts={posts} />
</Layout>,
);
});
GET /users/{username}/posts/{id}
요청 핸들러도 수정합니다:
app.get("/users/:username/posts/:id", (c) => {
// ... 생략 ...
if (post == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 언제나 하나의 레코드를 반환
const { following, followers } = db
.prepare<unknown[], { following: number; followers: number }>(
`
SELECT sum(follows.follower_id = ?) AS following,
sum(follows.following_id = ?) AS followers
FROM follows
`,
)
.get(post.actor_id, post.actor_id)!;
return c.html(
<Layout>
<PostPage
name={post.name ?? post.username}
username={post.username}
handle={post.handle}
following={following}
followers={followers}
post={post}
/>
</Layout>,
);
});
다 수정되었다면, 웹 브라우저에서 http://localhost:8000/users/johndoe 페이지를 열어 봅시다:
많은 것들을 구현했지만, 아직 다른 Mastodon 서버에서 쓴 게시물이 보이지는 않고 있습니다. 여태까지의 과정에서 짐작할 수 있다시피, 우리가 게시물을 쓸 때 Create(Note)
액티비티를 발신했던 것과 같이, 다른 서버로부터 Create(Note)
액티비티를 수신해야 다른 Mastodon 서버에서 쓴 게시물이 보이게 됩니다.
다른 Mastodon 서버에서 글을 쓰면 구체적으로 어떤 일이 일어나는지 보기 위해, ActivityPub.Academy에서 새로운 게시물을 작성해 봅시다:
Publish! 버튼을 눌러 게시물을 저장한 뒤, Activity Log 페이지로 들어가 Create(Note)
액티비티가 과연 잘 발신되었나 확인합니다:
이제 이렇게 발신된 Create(Note)
액티비티를 수신하는 코드를 짜야 합니다.
Create(Note)
액티비티 수신src/federation.ts 파일을 열어 Fedify가 제공하는 Create
클래스를 import
합니다:
import {
Accept,
Create, // 추가됨
Endpoints,
Follow,
Note,
PUBLIC_COLLECTION,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
isActor,
type Actor as APActor,
type Recipient,
} from "@fedify/fedify";
그리고 수신함 코드에 on(Create, ...)
를 추가합니다:
.on(Create, async (ctx, create) => {
const object = await create.getObject();
if (!(object instanceof Note)) return;
const actor = create.actorId;
if (actor == null) return;
const author = await object.getAttribution();
if (!isActor(author) || author.id?.href !== actor.href) return;
const actorId = (await persistActor(author))?.id;
if (actorId == null) return;
if (object.id == null) return;
const content = object.content?.toString();
db.prepare(
"INSERT INTO posts (uri, actor_id, content, url) VALUES (?, ?, ?, ?)",
).run(object.id.href, actorId, content, object.url?.href);
});
getAttribution()
메서드를 이용하여 글쓴이를 구한 뒤, persistActor()
함수를 통해 해당 액터가 아직 actors
테이블에 없으면 추가합니다. 그리고 posts
테이블에 새 레코드를 하나 추가합니다.
코드가 잘 작동하는지 확인하기 위해 다시 한 번 ActivityPub.Academy에 들어가 게시물을 작성해 봅시다. Activity Log를 열어 Create(Note)
액티비티가 발신되었는지 체크한 뒤, 아래 명령으로 posts
테이블에 정말 레코드가 추가되었나 확인합니다:
echo "SELECT * FROM posts WHERE actor_id != 1" | sqlite3 -table microblog.sqlite3
정말 레코드가 추가되었다면 아래와 같은 결과가 나와야 합니다:
id |
uri |
actor_id |
content |
url |
created |
---|---|---|---|---|---|
3 |
https://activitypub.academy/users/algusia_draneoll/statuses/113068684551948316 |
3 |
<p>Would it send a Create(Note) activity?</p> |
https://activitypub.academy/@algusia_draneoll/113068684551948316 |
2024-09-02 15:33:32 |
자, 이제 원격 게시물을 posts
테이블에 레코드로 추가했으니, 이제 그 레코드들을 잘 표시해 주는 일만 남았습니다. 흔히 「타임라인」이라고 불리는 기능입니다.
먼저 src/views.tsx 파일을 열어 <Home>
컴포넌트를 수정합니다:
export interface HomeProps extends PostListProps { // extends 추가됨
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('메롱');</script>
같은 HTML을 포함한 Create(Note)
액티비티를 보내는 공격을 할 수 있습니다. 이를 XSS 취약점이라고 합니다. 이러한 취약점은 어떻게 막을 수 있을까요?
SQLite 데이터베이스에서 다음 SQL을 실행하여 우리가 만든 액터의 이름을 바꿔 봅시다:
UPDATE actors SET name = 'Renamed' WHERE id = 1;
이렇게 액터의 이름을 바꿨을 때, 다른 Mastodon 서버에서 바뀐 이름이 적용될까요? 적용되지 않는다면, 어떤 액티비티를 보내야 변경이 적용될까요?
액터에 프로필 사진을 추가해 봅시다. 프로필 사진을 추가하는 방법이 궁금하다면, fedify lookup
명령으로 이미 프로필 사진이 있는 액터를 조회해 보세요.
다른 Mastodon 서버에서 이미지가 첨부된 게시물을 작성해 봅시다. 우리가 만든 타임라인에서는 게시물에 첨부된 이미지가 보이지 않습니다. 어떻게 하면 첨부된 이미지를 표시할 수 있을까요?
게시물 내에서 다른 액터를 멘션할 수 있게 해봅시다. 멘션한 상대한테 알림이 가도록 하려면 어떻게 해야 할까요? ActivityPub.Academy의 Activity Log를 활용하여 방법을 찾아보세요.