Skip to content
created by Aha00aAha00a at 2026-06-24
last modified by Aha00aAha00a at 2026-06-28
revision: 7

Dev Api

외부 자동화 도구가 AhaWiki 페이지를 직접 수정할 수 있도록 사용자 개인 API Key 인증을 추가했다.

세션 쿠키와 reCAPTCHA 없이 Authorization: Bearer <key> 헤더로 페이지를 읽고 저장할 수 있는 API를 제공한다.

페이지 히스토리에는 편집자가 기존 사용자로 기록되며, API Key를 통한 편집은 Page.viaApi = TRUE로 표시한다.

1. 설계 결정

  • API Key는 기존 사용자 계정에 직접 연결한다. 자동화 전용 계정을 별도로 만들지 않는다.
  • API Key로 저장한 revision은 Page.viaApi = TRUE로 기록한다.
  • 어느 key로 저장했는지는 Page.userApiKey nullable FK로 함께 기록한다.
    • viaApi(불변 사실)와 userApiKey(삭제/이름변경 가능한 엔티티 참조)는 서로 다른 질문에 답하므로 둘 다 유지한다.
    • 불변식: userApiKey IS NOT NULL이면 viaApi = TRUE다. 역은 성립하지 않는다(viaApi = TRUE && userApiKey = NULL은 "API로 저장됐지만 그 키는 이후 삭제됨").
    • key를 hard delete해도 viaApi 사실이 보존되도록 ON DELETE SET NULL을 쓴다. 현재 key는 soft-delete(dateRevoked)만 하므로 평상시 FK는 끊기지 않는다.
  • UserApiKey.name은 사람이 읽을 수 있는 키 이름이다. 화면/응답에는 join으로 현재 이름을 노출하고, key가 없으면 이름 없이 viaApi만 표시한다.
  • API Key 원문은 DB에 저장하지 않고 SHA-256 hash만 저장한다. 원문 key는 생성 응답에서 한 번만 보여준다.
  • AhaWiki API는 Bearer 인증을 사용하므로 CSRF token과 reCAPTCHA를 요구하지 않는다.
  • AhaWiki API의 읽기/쓰기 권한은 기존 WikiPermission 규칙과 key 소유자 사용자의 권한을 그대로 따른다.
  • API Key 관리는 사용자가 Account Settings에서 직접 한다. Admin은 전체 key 조회와 강제 폐기만 할 수 있다.

2. Data Model

2.1. UserApiKey

UserApiKey 테이블은 사용자별 API Key 메타데이터와 hash를 저장한다.

  • seq BIGINT AUTO_INCREMENT PRIMARY KEY
  • user INT NOT NULLUser.seq FK
  • keyHash VARCHAR(64) NOT NULL UNIQUE — SHA-256 hex
  • keyPrefix VARCHAR(32) NOT NULL — 목록에서 key를 식별하기 위한 prefix
  • name VARCHAR(255) NOT NULL — 사람이 읽을 수 있는 키 이름 (이전 label에서 rename)
  • dateInserted DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  • dateLastUsed DATETIME NULL
  • dateRevoked DATETIME NULL
  • INDEX (user, dateRevoked) — 사용자별 key 목록 조회

keyHash UNIQUE가 인증 조회 인덱스 역할을 하므로 별도 INDEX (keyHash)는 만들지 않는다.

2.2. Page.viaApi

Page 테이블에 viaApi BOOLEAN NOT NULL DEFAULT FALSE 컬럼을 추가했다.

  • 일반 웹 편집은 기본값 FALSE다.
  • API Key 저장만 TRUE로 기록한다.
  • Page, PageWithoutContent, row parser, history 조회, insert SQL에 모두 viaApi를 포함한다.
  • PageLogic.insertviaApi: Boolean = false 기본값을 받고, API Key 저장 시 true를 전달한다.

2.3. Page.userApiKey

Page 테이블에 userApiKey BIGINT NULL 컬럼과 Page_UserApiKey_seq_fk FOREIGN KEY (userApiKey) REFERENCES UserApiKey (seq) ON DELETE SET NULL을 추가했다.

  • 웹 편집은 NULL이고, API Key 저장만 해당 key의 seq를 기록한다.
  • Page에는 userApiKey: Option[Long] seq만 둔다. PageWithoutContent는 history 표시용으로 join한 userApiKeyName: Option[String]도 갖는다.
  • PageLogic.insertuserApiKey: Option[Long] = None 파라미터를 받는다.
  • ApiV1.savePage / renamePagewithApiUserAndKey로 인증 key를 받아 userApiKey = Some(apiKey.seq)를 전달한다.
  • 키 이름은 UserApiKeyLEFT JOIN해 현재 이름을 조회한다. 비정규화 스냅샷은 두지 않는다.

3. 인증과 권한

SessionLogic.getApiKeyUser(request)Authorization: Bearer <key> 헤더를 읽고 raw key를 SHA-256으로 hash한 뒤 UserApiKey에서 활성 key를 조회한다.

인증 성공 시:

  • dateLastUsed를 갱신한다.
  • key 소유자의 User.SessionUser를 만든다.
  • primary email을 함께 담아 기존 email 기반 permission과 호환한다.

getApiKeyWithUser는 인증 key(UserApiKey)와 SessionUser를 함께 반환하고, getApiKeyUser는 그중 사용자만 돌려준다.

저장/이름변경처럼 userApiKey를 기록해야 하는 endpoint는 withApiUserAndKey로 둘을 함께 받는다.

AhaWiki API에서는 RequestWrapper.forUser(user)로 인증 사용자를 ContextWikiPageWikiPermission에 전달한다.

이 처리가 없으면 인증은 성공해도 권한 계산이 익명 사용자 기준으로 동작할 수 있다.

4. API와 UI

4.1. AhaWiki API

외부 사용 설명서는 Api에 둔다.

  • GET /api/v1/page/*nameEncoded
  • POST /api/v1/page/*nameEncoded
  • GET /api/v1/pages
  • POST /api/v1/pages/metadata
  • GET /api/v1/changes
  • POST /api/v1/rename
  • DELETE /api/v1/page/*nameEncoded

저장 API는 JSON body의 revision, text, comment, minorEdit를 사용한다.

revision이 최신과 다르면 409 Conflict를 반환한다.

현재 API 저장은 PageLogic.insert(..., viaApi = true), cache invalidate, page calculation enqueue까지만 수행한다.

웹 편집의 websocket broadcast와 Telegram 알림은 보내지 않는다.

Telegram 알림은 minorEditviaApi 저장을 제외한다.

4.2. AhaWikiDoc Sync 지원

AhaWikiDoc sync를 위해 AhaWiki API를 보강했다.

  • 페이지 목록/메타 API는 name, revision, dateTime, isMinorEdit, viaApi, userApiKeyName, contentHash를 반환한다.
  • Batch metadata API는 여러 page name의 revision, dateTime, hash를 한 번에 조회한다.
  • 최근 변경 API는 API Key 인증으로 page prefix, timestamp, page별 revision 기준 필터를 지원한다.
  • afterRevision은 페이지별 revision이므로 name으로 단일 페이지를 지정한 경우에만 허용한다.
  • 이름변경 API는 revision 확인 후 기존 페이지를 rename하고, 기존 이름에는 viaApi = true redirect page를 만든다.
  • 삭제 API는 revision과 confirm: true를 요구하며, 웹 삭제와 같은 정책으로 첨부파일도 삭제 처리한다.
  • sync state는 서버에 저장하지 않고 local manifest에 lastSyncedAt, page별 revision, dateTime, contentHash를 기록하는 방식을 권장한다.
  • 문서 rename sync는 delete+create가 아니라 POST /api/v1/rename으로 기존 page history를 보존한다.

4.3. Account Settings

Account Settings에 API Key 관리 섹션을 추가했다.

  • 내 API Key 목록
  • name 기반 key 생성
  • 생성 직후 plain text key 1회 표시와 복사 버튼
  • key 폐기 버튼

목록 조회에서는 plain text key를 반환하지 않고 keyPrefix만 보여준다.

세션 기반 내부 API:

  • GET /api/account/ApiKeys
  • POST /api/account/ApiKeys
  • DELETE /api/account/ApiKeys/:seq

이 API는 로그인 세션과 CSRF token이 필요하다.

POST 응답에만 plain text key를 포함하고, 이후 조회에서는 keyPrefix만 반환한다.

4.4. Admin UI

Admin SPA에 /Admin/ApiKeys 화면을 추가했다.

  • 전체 API Key 목록
  • user nickname, name, key prefix, 생성일, 마지막 사용일, 폐기 상태
  • Admin 강제 폐기

세션 기반 내부 Admin API:

  • GET /api/Admin/ApiKeys
  • DELETE /api/Admin/ApiKeys/:seq

Admin 권한과 CSRF token이 필요하다.

5. 변경 이력 표시

viaApi는 사용자가 자동화 편집을 구분할 수 있도록 여러 화면과 API에 노출한다.

  • Api.change 응답에 viaApi 포함
  • Api.adminRecentChanges 응답에 viaApi 포함
  • Wiki history 화면에 minor edit, Via API 이모지 컬럼과 Show ViaApi 필터 추가
  • RecentChanges 매크로에 Include via API edits 토글과 minor edit, Via API 이모지 컬럼 추가
  • /api/changeincludeViaApi 파라미터 추가
  • Admin Recent Changes 페이지에 viaApi 포함/제외 토글과 minor edit, Via API 이모지 컬럼 추가
  • Admin Dashboard의 Recent Changes 요약 표에 minor edit, Via API 이모지 컬럼 추가

viaApi 편집은 어느 key였는지도 함께 보여준다. key 이름은 UserApiKey join으로 조회하며, key가 삭제됐으면 이름 없이 viaApi만 남는다.

  • Api.change, Api.adminRecentChanges, ApiV1.changes, 페이지 목록/메타(ApiV1.listPages / pageMetadata / getPage) 응답에 userApiKeyName 포함
  • Wiki history 화면의 Via API 컬럼에 key 이름을 함께 표시(있을 때)
  • RecentChanges 매크로는 각 revision comment에 [viaApi:<name>] prefix로 표시
  • Admin Recent Changes / Dashboard의 Via API 셀에 key 이름을 함께 표시(makeFlagCelldetail 인자)

6. Security

  • API Key는 SecureRandom으로 32 bytes를 생성하고 Base64 URL-safe 문자열로 표시한다. 형식은 ahawiki_<token>이다.
  • DB에는 raw key를 저장하지 않는다.
  • 고엔트로피 API Key이므로 SHA-256 hash로 비교한다. bcrypt/Argon2 같은 slow hash는 요청마다 불필요한 지연을 만든다.
  • AhaWiki API는 /api/v1/ 경로에서 CSRF filter를 우회한다.
  • 브라우저 세션 기반 Account/Admin API는 기존 CSRF 정책을 유지한다.
  • 별도 API Key 단위 rate limit은 1차 구현에 포함하지 않았다. 현재 전역 IP rate limit은 AhaWiki API에도 적용된다.

7. Tests

  • ApiV1Spec
    • API Key hash 저장
    • 유효 key 인증 성공
    • 폐기 key와 존재하지 않는 key 인증 실패
    • 읽기/쓰기 권한 403
    • API 읽기
    • API 저장과 viaApi = TRUE, userApiKey seq 기록, 읽기 응답의 userApiKeyName 노출
    • revision 충돌 409 Conflict
    • 페이지 목록/메타 조회
    • 최근 변경의 since, includeMinorEdit, includeViaApi, invalid since 검증
    • afterRevision을 단일 페이지에만 허용하는 정책 검증
    • API 이름변경과 redirect 생성
    • API 삭제와 첨부파일 삭제 표시
    • Account API 생성/목록/폐기
  • ApiV1FilterSpec
    • 실제 Filters 체인에서 /api/v1/ POST가 CSRF token 없이 Bearer 인증만으로 저장되는지 검증
  • UnitTestSuiteSpec
    • 테스트용 수동 Page schema에 viaApi, userApiKey 컬럼 추가

8. 검증

관련 테스트:

  • sbt.bat "testOnly com.aha00a.controllers.ApiV1Spec"
  • sbt.bat "testOnly com.aha00a.controllers.ApiV1FilterSpec"

마지막 확인 시 ApiV1Spec 18개 테스트가 통과했다.

9. See Also

9.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • Same Wiki
    • 84.80% Api api(129:85), key(66:32), via(35:21), page(27:29), user(38:8), wiki(17:15), name(12:18), v1(13:15), aha(12:14), revision(8:18)
    • 71.59% ToDo ApiKey api(129:29), key(66:9), via(35:2), page(27:8), wiki(17:2), revision(8:7), aha(12:1), name(12:1), account(9:4), api는(11:1)
    • 54.34% Dev ApiControllers api(129:18), key(66:2), page(27:1), admin(15:10), wiki(17:5), v1(13:1), 페이지(4:6), 목록(8:2), seq(8:1), 인증(7:1)
    • 42.09% Dev ApiResponse api(129:33), admin(15:26), json(1:32), page(27:6), wiki(17:3), get(9:10), v1(13:4), seq(8:6), name(12:1), revision(8:5)
  • Sister Wikis
    • 36.86% Aha00a:RecentChanges api(129:2), user(38:1), via(35:2), page(27:3), wiki(17:1), name(12:1), changes(9:3), minor(8:2), date(9:1), recent(7:3)
    • 34.62% Aha00a:PlantUML api(129:30), include(4:18), aha(12:1), sync(3:3), request(2:3), pages(3:1), and(2:2), content(3:1), insert(2:1), primary(2:1)
    • 33.95% WhoHow:FrontPage api(129:2), user(38:1), via(35:2), page(27:2), wiki(17:1), name(12:1), changes(9:3), recent(7:4), minor(8:2), date(9:1)
    • 32.71% AhariseWiki:FrontPage api(129:2), user(38:1), via(35:2), page(27:2), wiki(17:2), name(12:1), changes(9:3), recent(7:4), minor(8:2), date(9:1)
    • 32.70% DringDring:FrontPage api(129:2), user(38:1), via(35:2), page(27:2), wiki(17:1), name(12:1), aha(12:1), changes(9:3), recent(7:4), minor(8:2)

9.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+