핀노트(Pinnote) 설계 문서
어떤 웹사이트에든 스크립트 한 줄로 붙인다. 사람들은 화면 요소에 핀을 꽂아 의견을 남긴다. 여러 사이트의 의견은 한곳에 모인다.
개발자(사람이나 AI)가 "어느 사이트·화면·요소에 대한 의견인지" 정확히 알고 고친다. 고친 뒤에는 건마다 완료 응답을 남긴다.
요소가 바뀌거나 사라져도 의견을 잃지 않는다.
- 주소: https://pinnote.itemview.co.kr (관리
/admin/, 데모/demo/, 문서/docs/) - 첫 적용 대상: 뉴팅 데모
https://imgx.kr
---
1. 비슷한 서비스 조사
2026-09-16에 각 서비스의 공식 문서와 도움말을 확인했다. 확인하지 못한 내용은 [미확인]으로 표시한다.
| 서비스 | 요소 고정 방식 | 상태 흐름 | 개발자 연동 | 보고자 순환 | 개인정보 |
|---|---|---|---|---|---|
| BugHerd | 요소에 핀. 기능 소개에 "CSS selector" 수집이 적혀 있음. 페이지 URL로 묶어서 SPA는 BugHerdConfig.location을 직접 갱신해야 함. 요소가 사라질 때 동작 [미확인] | Backlog·Todo·Doing·Done·Closed(열 편집 가능) | REST API v2, 웹훅, Jira·GitHub·Linear 등 양방향, 공식 MCP 서버 | 게스트에게 "완료" 메일. 공개 위젯으로 온 익명 보고자에게는 알림 없음 | 세션 리플레이 [미확인] |
| Marker.io | 스크린샷 중심(주석 달린 캡처 + 콘솔·네트워크·환경). 선택자는 메타데이터로만 있음. 실제 페이지에 다시 붙이지 않음 | Open·In Progress·Waiting for Approval·Resolved·Closed | 공개 REST API 없음. 웹훅, 이슈 트래커 동기화, MCP(베타, 스크린샷 서명 URL 제공) | Resolved가 되면 보고자에게 메일 | .mk-mask·.mk-exclude로 캡처 전에 브라우저에서 가림. 비밀번호는 항상 가림 |
| Userback | 스크린샷 + URL + 요소 강조 | 사용자 정의. 처음(Open)과 끝(Resolved)은 고정 | REST API, 웹훅, JS SDK, MCP | "Resolved & Auto-reply". 보고자가 답하면 다시 열림 | 이메일·비밀번호 입력 기본 가림, .userback-block |
| Usersnap | 스크린샷 위 핀. 스크린샷은 서버에서 렌더링 | open 등 [전체 목록 미확인] | 게이트된 REST API(JWT), 커서 페이지 나누기, HMAC 서명 웹훅 | [미확인] | usprivacy 클래스는 렌더러로 보내지 않음 |
| Pastel | 프록시 캔버스에서 요소에 핀 + 스크린샷. 사이트가 바뀌면 "버전"을 새로 만들고 옛 버전은 읽기 전용 | Active·In Progress·In Review·Resolved | Trello·Jira 내보내기, 웹훅, 에이전트용 MCP | 상태가 바뀔 때마다 작성자에게 알림 | 리플레이 없음 |
| Ruttl | 요소를 골라 댓글. 버전마다 댓글 이력 유지. 페이지 승인 기능 | 칸반 [상태 이름 미확인] | API·웹훅·Jira 등 | 링크로 게스트 댓글 | [미확인] |
| Hotjar Feedback | 요소 강조 + 스크린샷. 2024-09에 Surveys로 합쳐짐 | 작업 흐름 없음(분석 도구) | API·웹훅 | 없음 | data-hj-suppress 기본 입력 가림 |
출처:
- BugHerd: bugherd.com/features, support.bugherd.com/en/articles/12812946 (SPA location), updates.bugherd.com (게스트 알림), bugherd.com/feature/mcp
- Marker.io: help.marker.io/en/articles/6572843 (공개 API 없음), /3738778 (웹훅), /9657817 (가림), /14034657 (MCP)
- Userback: support.userback.io/en/articles/5209239 (작업 흐름), /5762135 (리플레이·가림), docs.userback.io
- Usersnap: help.usersnap.com/reference/rest-api, /docs/webhook, /docs/privacy-and-security-faq
- Pastel: help.usepastel.com/en/articles/5721890 (버전), /5324362 (상태), usepastel.com/agents
- Ruttl: ruttl.com/features/comment-on-website
- Hotjar: help.hotjar.com (suppress)
배운 점
- 요소를 찾는 방법을 하나만 저장하면 핀이 떠돈다. 어떤 서비스도 "다시 찾는 방법"을 공개하지 않았다. 경쟁사는 BugHerd의 핀이 떠돈다고 공격한다([미확인] 주장). 그래서 찾는 방법을 여러 겹 저장한다. 어떤 방법으로 찾았는지와 신뢰도도 남긴다.
- "못 찾음"은 조용히 넘기면 안 되는 상태다. 좌표로 대충 붙이면 엉뚱한 요소에 의견이 달린다. 차라리 "사라진 요소" 목록에 모은다. 원래 모습(캡처)을 보여 주고 사람이 [다시 연결]하게 한다.
- 처음 모습(스크린샷·HTML)이 기준 자료다. Marker·Usersnap·Pastel·Userback 모두 캡처를 붙인다. AI 연동(MCP)도 결국 "선택자 + 스크린샷"을 넘긴다. DOM이 바뀐 뒤에도 의견의 뜻을 살리는 것은 캡처다.
- 페이지는 URL이 아니라 "화면 키"로 묶는다. BugHerd의 SPA 문제가 그 증거다. 경로 규칙(
/t/:token)으로 정규화하고, 앱 버전(data-version)을 함께 저장한다. 그래야 "요소가 바뀐 것"과 "다른 화면인 것"을 구분할 수 있다. - 상태는 짧게 두고, 처음과 끝을 고정하고, 모든 전이를 기록한다. Marker·Pastel·Userback은 해결되면 보고자에게 알리지만, 우리는 알리지 않는다(처리 시점 추적 방지, §6).
- 가림은 캡처 전에 브라우저에서 한다.
.mk-mask나data-hj-suppress처럼 클래스·속성으로 받는다. 입력값은 기본으로 가린다. - 웹훅만으로는 부족하다. 진짜 REST API와 AI가 바로 읽을 문서가 필요하다. Marker의 가장 큰 약점이 공개 API가 없다는 점이다. 우리는 토큰 REST, CLI, 사이트→화면→요소→의견 마크다운 내보내기를 기본으로 둔다.
---
2. 전체 구조
워크스페이스 ─ 프로젝트 ─ 사이트(허용 도메인, 공개 키 pk_…, 환경 demo/staging/prod, 경로 규칙)
└ 페이지(정규화된 화면 키)
└ 요소 앵커(찾는 방법 여러 겹 + 원래 모습 + 상태)
└ 의견(아이템) ─ 댓글 · 공감 · 첨부(요소 캡처)
└ 히스토리(모든 변경)
- 서버: PHP 8.2 + MariaDB. 프레임워크·빌드 도구 없음.
lib/도메인 로직,public/웹 루트. - 위젯: 순수 JS.
/w.js는 캐시하지 않는 약 1KB 로더다. 로더가 버전이 붙은 본체/widget/pinnote.js?v=…를 불러오고, 본체는 1년 캐시한다. Cloudflare가.js캐시 시간을 4시간으로 늘려 버리는 문제를 이렇게 피했다. - API 두 갈래
- 위젯 API
/api/w/*: 공개 키 + Origin 검사, 쿠키 없음 - 개발자 API
/api/v1/*: 토큰 - 권한 모델과 한도가 다르므로 나눴다.
데이터 표 (db/migrations/001_init.sql)
| 표 | 역할 |
|---|---|
| workspaces, projects, sites | 구조. sites.domains·page_rules·query_keys·capture_mode·missing_visits/days·rate_per_hour |
| members | 관리자 이름 + 역할(OWNER/ADMIN/MEMBER/VIEWER). 지금은 VIEWER만 쓰기 금지 |
| pages | (site_id, page_key) 유일. 제목·예시 주소·방문 수 |
| anchors | 찾는 방법(ui_key, elem_id, selector, dom_path, tag, role, aria_name, text_fp, landmark, rel_x/y, locators JSON), 원래 모습(snapshot_html/style/rect, shot_id), 상태(status, miss_count, first_missed_at, last_visit_id, relocated_count, missing_resolution) |
| items | 종류·상태·중요도·본문·보고자·담당자, 문맥(URL·버전·환경·화면 크기·스크롤·기기·언어·JS 오류·사용자 문맥), 완료 응답(resolution_note, resolved_version, resolved_ref), status_reason, duplicate_of, 공감·댓글 수, client_uid(재전송 중복 방지) |
| item_labels, comments, votes, attachments | 라벨, 댓글, 공감(보고자당 1), 캡처 파일(웹 루트 밖 storage/shots) |
| reporters | 브라우저 비밀 키의 HMAC 해시 + 이름. 원문 키는 저장하지 않는다 |
| history | 감사 기록: entity(item/anchor/site/token), action, 이전→이후, 메모, data, 작업자(MEMBER/REPORTER/API/CLI/WIDGET/SYSTEM + 이름), request_id |
| api_tokens | 토큰 해시만 저장(원문은 만들 때 한 번만 보여 줌), read/write |
| rate_limits, activity_logs | 시간당 제한 칸, 요청·보안·오류 기록 |
---
3. 수집하는 구분자
| 층 | 값 | 어디서 |
|---|---|---|
| 사이트 | 공개 키 → 사이트, Origin 도메인 | 설치 한 줄 data-site |
| 페이지 | 전체 URL, 정규화된 화면 키, 제목 | 위젯 → 서버가 키 계산 |
| 앱 | data-version, data-env | 설치 한 줄 |
| 요소 | 아래 "찾는 방법" 9가지 | 위젯 |
| 원래 모습 | outerHTML 앞 3500자(가림), 주요 계산 스타일 18개, 크기·위치, 요소 주변 캡처(JPEG) | 위젯 |
| 환경 | 화면 크기@DPR, 스크롤, UA, 언어, 최근 JS 오류 5개 | 위젯 |
| 사용자 문맥 | Pinnote.setContext({screen, journey_id, …}) 최대 30개 키 | 사이트 코드 |
화면 키 정규화 (lib/site.php page_key_for)
- 경로만 쓴다. 해시 라우팅(
#/…)은 경로로 본다.index.php와 끝 슬래시는 뗀다. - 사이트 경로 규칙에서 앞에서부터 처음 맞는 규칙이 키가 된다.
:이름은 한 조각,*은 나머지 전부와 맞는다. 예:/t/abc123→/t/:token. - 맞는 규칙이 없으면 숫자·UUID·20자 넘는 토큰 조각을
:id로 바꾼다. query_keys에 적힌 쿼리만 정렬해서 붙인다. 나머지 쿼리(pinnote_focus, 추적 파라미터 등)는 버린다.
요소를 찾는 방법 (저장, widget/pinnote.js makeLoc)
data-ui속성: 사이트가 붙이는 안정 식별자. 1순위다.- id:
\d{4,}, 해시,react·radix같은 자동 생성 id는 제외한다. - 짧은 CSS 선택자: 가까운
data-ui/id 조상 +tag.class/:nth-of-type. 유일해질 때까지 최대 6단계. 상태 클래스(active,is-*,css-*해시)는 제외한다. - 전체 DOM 경로:
html > body > main:nth-of-type(1) > … - 태그·역할·접근성 이름: aria-label, labelledby, label[for], alt, title, placeholder, 버튼 텍스트
- 텍스트 지문: 공백을 정리한 innerText 앞 120자
- 가까운 기준 요소: 가장 가까운 section/form/nav/…/[data-ui] 안의 첫 제목. 예:
h2 "장바구니" - 페이지 기준 좌표 비율
rx, ry: 요소 중심 / 문서 크기 - 보조 속성: 안정 클래스 최대 8개, href 경로, name, type, placeholder, alt, src 파일명
---
4. 요소가 바뀌거나 사라질 때
다시 찾기 (위젯 findAnchor)
- 1순위(ATTACHED): 저장된 가장 강한 식별자가 그대로 맞는 경우다. data-ui가 있으면 data-ui, 없으면 id, 그것도 없으면 선택자(태그가 같고 텍스트 유사도 ≥ 0.5)를 본다.
- 보조(RELOCATED)
- 선택자나 DOM 경로가 유일하게 맞고, 태그가 같고, 텍스트 유사도 ≥ 0.8이면 찾은 것으로 본다.
- 그래도 안 되면 같은 태그 후보(최대 3000개)에 점수를 매긴다.
- 텍스트 같음 45 / 비슷함 30
- 접근성 이름 20
- 속성 최대 24
- 클래스 겹침 15
- 기준 요소 15
- 역할 5
- 좌표 거리 10
- 최고 점수가 55 이상이고 2등보다 10점 이상 높을 때만 인정한다. 애매하면 못 찾음으로 본다. 엉뚱한 요소에 붙이는 것보다 낫다.
- 못 찾음: 늦게 그려지는 화면을 고려해 1.2초 뒤 한 번 보고, 없으면 2.5초 뒤 한 번 더 본다.
보고와 상태 전이 (서버 anchor_report)
- 위젯은 방문(visit id)마다 결과를 한 번에 묶어 보낸다. 같은 방문에서 온 중복 보고는 무시한다.
- 표본 추출: 서버는 앵커마다
check를 내려 준다. 상태가 ATTACHED이고, 최근 60초 안에 누군가 확인했고, 못 찾은 적이 없는 건강한 앵커는 보고를 건너뛴다. 핀은 그래도 그린다. primary→ ATTACHED. 못 찾음 횟수를 0으로 되돌린다. 이전 상태가 달랐다면 히스토리에 "다시 찾음"을 남긴다.fallback→ RELOCATED- 새 찾는 방법으로 갱신하고 히스토리에 이전→이후 선택자와 점수를 남긴다.
- 다음 방문에 새 식별자로 1순위에 맞으면 ATTACHED로 돌아간다. 위치 갱신 횟수는 남는다.
none→miss_count+1. 방문 N번(기본 3) 연속 못 찾거나, 처음 못 찾은 뒤 N일(기본 3)이 지나면 MISSING이 된다. 사이트마다 바꿀 수 있다.- 원래 모습(snapshot, 첫 캡처)은 절대 덮어쓰지 않는다.
사라진 요소 목록 (/admin/missing.php)
- 기본으로는 열린 의견이 있는 MISSING 요소만 보여 준다. 해결된 뒤 요소가 사라지는 것은 정상이므로 "해결 뒤 사라진 것도 보기"에서만 나온다.
- 카드에는 캡처 이미지, 격리된 iframe에 넣은 원래 HTML(스크립트 금지 sandbox + CSP), 선택자·텍스트·기준 요소, 의견 목록을 보여 준다.
- 조치
- [수정으로 해결됨]: 완료 응답이 필수다. 열린 의견을 모두 RESOLVED로 바꾸고 앵커는 ARCHIVED(
missing_resolution=FIXED)가 된다. - [다시 연결]: 서명된 24시간 링크
?pinnote=1&pinnote_relink=앵커.만료.서명으로 실제 페이지를 연다. 위젯이 재지정 모드로 들어가 원래 모습을 보여 준다. 새 요소를 누르고 [연결]하면 앵커가 ATTACHED가 된다(RELINKED, 히스토리에 이전→이후). - [보관]: ARCHIVED가 되고 더 이상 찾지 않는다.
- API로 선택자를 직접 지정할 수도 있다:
POST /api/v1/items/{id}/relink {"selector": "..."}
---
5. 상태 흐름
NEW ─┬→ TRIAGED ─┬→ IN_PROGRESS ─┬→ RESOLVED ─┬→ VERIFIED ─→ REOPENED
│ │ │ (완료 응답 필수)└→ REOPENED ─→ (TRIAGED/IN_PROGRESS/…)
├→ NEEDS_INFO (메모) │
├→ WONT_FIX (사유 필수) ─→ REOPENED
└→ DUPLICATE (원본 번호 필수) ─→ REOPENED
- 허용되는 이동은
lib/item.phpPN_TRANSITIONS한곳에 정의한다. 관리 화면·REST·CLI·위젯이 모두 같은 함수(item_transition)를 쓴다. - RESOLVED:
note(무엇을 어떻게 고쳤는지)가 없으면 422RESOLUTION_REQUIRED. 버전과 커밋 참조는 선택. - WONT_FIX: 사유 필수. DUPLICATE: 같은 사이트의 다른 의견 번호가 필수이고, 서로를 가리키는 순환은 금지.
- 동시성: 트랜잭션 안에서
FOR UPDATE로 현재 상태를 다시 읽는다. 다르면 409CONFLICT. - 히스토리: 등록·상태·중요도·담당자·종류·라벨·댓글(id만, 내용 복사 안 함)·공감·캡처·요소 상태·위치 갱신·다시 연결·보관·사이트 설정·토큰. 누가(유형+이름), 언제(ms), 이전→이후, 메모, 부가 데이터를 남긴다.
- 의견 상세 타임라인은 그 의견의 기록과, 의견이 달린 요소의 기록(등록 이후)을 합쳐 보여 준다.
---
6. 보고자 (로그인 없음)
- 보고자에게 처리 시각을 보여 주지 않는다(작업 시간 노출 방지). 위젯 API는 의견·댓글의 작성·처리 시각(
created_at·updated_at·resolved_at·verified_at)을 응답에 넣지 않고, 위젯도 "N분 전"을 그리지 않는다. 관리 화면·REST·CLI는 시각을 그대로 보여 준다. - 보고자 알림·보고자 확인은 작업 시점 추적이 가능해 제거함. 예전의 "내가 남긴 의견 N건이 처리됐어요" 알림(
init의notices)과 위젯의 [확인했어요]/[아직이에요](/api/w/verify)는 없다. VERIFIED·REOPENED는 관리 화면·REST·CLI에서만 바꾼다. - 위젯은
localStorage에 32자 비밀 키(rk)를 만든다. 서버는 HMAC 해시만 저장한다. 이 키는 의견 작성자 구분(내 의견 표시)과 공감 중복 방지에만 쓴다. 이름은 한 번 적으면 기억한다. - 이 키는 사이트 도메인마다 따로다. 브라우저 저장소가 출처마다 나뉘기 때문이다. 제3자 저장소 분할 때문에 사이트를 넘나드는 식별은 하지 않는다.
---
6-1. 위젯 열기·닫기 (데모 운영)
- 데모가 끝나면 관리 화면(첫 화면 위젯 줄·사이트 목록)의 [위젯 닫기], 다음 데모 때 [위젯 열기].
sites.is_active토글 + 히스토리(widget_closed/widget_opened, 누가). - 닫힌 사이트의 위젯 API는 어떤 동작이든
200 {closed:true}만 준다(허용 도메인 검사는 먼저 한다). 위젯은 이것을 받으면 버튼·핀·토스트를 그리지 않고, 콘솔 출력·재시도·보관함 재전송도 하지 않는다. 보관함(localStorage)의 전송 대기 의견은 지우지 않고 다음에 열렸을 때 보낸다. - 닫아도 의견·앵커·히스토리·스냅샷은 지우지 않는다. 다시 열면 상태·완료 응답이 그대로 보인다.
7. 위젯
- 격리
<pinnote-root>(크기 0, fixed)를<html>끝에 붙이고 그 안의 Shadow DOM에만 그린다.- 스타일은 생성형 스타일시트(
adoptedStyleSheets)로 넣는다. 그래서 호스트 CSP가 인라인 style을 막아도 동작한다. 지원하지 않는 브라우저에서는<style>을 쓴다. - 위젯 안의 click·key 이벤트는 shadow root에서 전파를 멈춘다. 호스트의 "바깥 클릭 닫기"나 단축키가 반응하지 않는다.
- 사용자 입력은 전부
textContent로 넣는다.innerHTML은 쓰지 않는다. - 핀
- 1순위는
[data-ui], 나머지는 휴리스틱(button, a[href], input, select, textarea, role=button/link/tab, img, h1~h3, summary)이다.[data-ui]안에서 크기가 거의 같은 자식은 뺀다. - 화면에 보이는 것만 최대 160개 그린다. 요소 오른쪽 위에 놓고, 다른 핀과 겹치면 옆·아래·왼쪽으로 옮긴다. 옮길 자리가 없으면 휴리스틱 핀은 생략한다.
- 크기는 마우스 22px, 터치 28px.
- 의견이 있는 요소의 핀은 개수 배지를 달고 색으로 상태를 보여 준다: 열림 빨강, 해결 초록, 닫힘 회색.
- scroll(capture)·resize·MutationObserver 변화는
requestAnimationFrame한 번으로 묶어 다시 그린다. 요소 목록 다시 모으기는 400ms 디바운스. - 모드
- 오른쪽 아래 📌 버튼으로 켜고 끈다(localStorage에 기억).
?pinnote=1로 켜고?pinnote=0으로 끈다. data-mode="always"면 항상 켜지고,off면 아무것도 그리지 않는다.- 켜면 도구 줄이 나온다: [요소 직접 고르기] [화면 전체 의견] [처리된 내 의견] [끄기].
- 요소 직접 고르기: 투명 막 +
elementsFromPoint로 요소를 고른다. 누른 곳에서 4단계 안에 의미 있는 조상(data-ui, button, a, input, label, li …)이 있으면 그 조상을 고른다. - 의견 창
- 데스크톱은 오른쪽 아래 떠 있는 창, 모바일은 아래에서 올라오는 창이다.
- 요소 요약(이름·선택자)과 이 요소의 기존 의견(종류·상태·작성자·공감 토글·댓글 스레드·완료 응답·본인이면 확인 버튼)을 보여 준다.
- 새 의견 폼: 종류 칩(오류·디자인·문구·아이디어·불편), 내용 2000자, 이름.
- 전송 실패
- 보내기 전에
localStorage보관함에 넣는다. 네트워크 오류·5xx·429면 보관한 채 창을 닫고,online이벤트나 20초마다 다시 보낸다. 캡처는 보관하지 않는다(용량). - 같은
uid를 다시 보내면 서버가 기존 의견을 돌려준다. 동시에 들어와 유일 키가 충돌해도 기존 것을 돌려준다. - 이미 보내는 중인 건은 재전송하지 않는다.
- 요소 캡처
- 창을 열 때
html2canvas(MIT,public/vendor/, 약 200KB)를 처음 필요할 때만 불러온다. 요소 주변 24px를 JPEG로 찍어 640px 폭 이하, 300KB 이하로 만든다. 시간 제한은 6초. - 다음 경우에는 끈다: 사이트 설정
capture_mode=off,data-capture="off", DOM 요소가 4000개를 넘는 무거운 페이지,navigator.deviceMemory ≤ 2. - 캡처 복제본에서도 입력값과
[data-pinnote-mask]/.pn-mask를 가린다. - 의견 저장 응답의 서명 토큰으로 따로 올린다. 의견 저장이 캡처를 기다리지 않는다.
- 포커스 모드
?pinnote=1&pinnote_focus={id}: 앵커를 찾는다(2번 재시도) → 가운데로 스크롤 → 빨간 테두리 4번 깜빡임 → 해당 의견 카드를 강조해 창을 연다. 못 찾으면 안내한 뒤 원래 정보로 창을 연다. - 재지정 모드
?pinnote_relink=…: 원래 모습(캡처 썸네일/라벨)을 띄우고 새 요소를 고르게 한 뒤 [연결]/[다시 고르기]를 묻는다. - SPA: 1초마다
location.href를 비교한다. 바뀌면 새 화면으로 다시 시작한다. - 공개 API
window.Pinnote.setContext(obj),.setName(str),.setPosition(obj),.open({page:true} | {selector} | {}),.on(ev, fn),.off,.enable(),.disable(),.toggle()- 이름 칸 기본값 우선순위:
setName()>localStorage에 저장된 이름 > 설치 한 줄의data-name.setName('')이면 지운다. 창이 열려 있으면 바로 반영한다. data-name-lock이 있고data-name이 비어 있지 않으면 그 이름으로 고정한다(이름 칸 읽기 전용,setName()무시).- 이벤트:
ready,submitted,modechange,anchors,relinked,focus,modal document에pinnote:submittedCustomEvent를 발행한다. 본체가 오기 전 호출은 로더의 대기열에 쌓였다가 넘어간다.
---
8. 개발자·AI 연동
- REST API v1 (
docs/API.md) Authorization: Bearer pn_…. 읽기와 쓰기 토큰을 나눈다.- 제공: 목록 필터·페이지 나누기, 상세(스냅샷·캡처 서명 URL·문맥·댓글·히스토리), 댓글, 상태 변경, 필드 변경, 다시 연결, 사라진 요소, 요소 조치, 내보내기(md/json), 통계.
- 오류는
{ok:false, error:{code, message}}와 올바른 HTTP 상태로 돌려준다. 에이전트가 code로 분기할 수 있다. - 쓰기 요청에
"author":"claude"를 넣으면 히스토리 작업자가API:토큰이름 (claude)로 남는다. - 마크다운 내보내기
- 구조: 사이트 → 화면(키·예시 주소) → 요소(이름, 선택자 여러 개, DOM 경로, 태그/역할/접근성 이름, 텍스트, 기준 요소, 요소 상태, 사라졌으면 원래 HTML) → 의견(번호·종류·중요도·상태·공감·작성자·버전·화면 크기·라벨, 인용 본문, 댓글, 완료 응답).
- 맨 위에 "고친 뒤 완료 응답 남기는 법"을 적는다.
- CLI
php bin/pinnote.php sites|list|show|resolve|status|comment|missing|export. 작업자는--as=이름.
---
9. 관리 화면
- 로그인: 관리자 비밀번호(
config.local.php) + 멤버 이름 선택·입력. 첫 멤버는 OWNER. - 쿠키는 HMAC 서명(14일)이고 HttpOnly·Secure·SameSite=Lax.
- 로그인은 IP당 시간당 30회로 제한한다.
- 화면
- 받은 의견: 필터(사이트·상태 묶음·종류·요소 상태·담당자·검색·정렬), 일괄 처리(상태·담당자·중요도, 해결·안 고침은 문구 필수)
- 사이트별 보기: 사이트 → 화면 → 요소 → 의견
- 의견 상세: 본문, 완료 응답, 상태 버튼(전이 규칙에 맞는 것만), 요소 정보, 원래 모습, 실제 페이지에서 보기, 다시 연결, 댓글, 정리(담당·중요도·종류·라벨), 문맥, 히스토리 타임라인
- 사라진 요소
- 사이트 설정: 설치 한 줄 복사, 키 교체, 도메인, 경로 규칙, 쿼리 키, 캡처, 사라짐 기준, 한도, 화면 키 확인 도구, 설정 기록
- API 토큰: 원문은 한 번만 보여 줌, 폐기
- 통계: 상태별, 종류별, 요소 상태, 평균 첫 해결 시간, 사이트별
- 모든 변경은 POST + CSRF(쿠키-폼 이중 제출)로 받는다. 관리 화면 CSP는
script-src 'self',frame-ancestors 'none'.
---
10. 보안
| 위협 | 대응 |
|---|---|
| 남의 사이트에서 위젯 API 호출 | 공개 키 + Origin 헤더 필수 + 허용 도메인(와일드카드 *.x) 검사. 보고하는 url의 호스트도 검사. 허용된 Origin에만 CORS 헤더. 쿠키를 쓰지 않음(credentials omit) |
| 스팸·폭주 | IP 해시(HMAC) 기준 시간당 제한. 사이트의 rate_per_hour에 동작별 배수(의견 ×1, 댓글 ×2, 공감 ×4), 조회·보고 1200, 사이트 전체. 본문 크기 제한(의견 96KB, 캡처 420KB, 나머지 8~64KB), 본문 2000자 |
| 잘못된 MISSING 보고 | 방문 id당 1회만 인정, N번·N일 기준, 보고 시간당 제한. 공개 키 보유자가 거짓 보고를 할 수는 있다(한계) |
| XSS | 서버 출력은 전부 htmlspecialchars. 위젯은 textContent만 사용. 스냅샷 HTML은 스크립트 없는 sandbox iframe + 내부 CSP로만 보여 줌. 서버에서 script/style/on*·민감 속성을 한 번 더 제거 |
| 민감 정보 | 위젯에서 input/textarea 값, select 선택, password, data-*token/key/secret/pass/auth/session/phone/email/card…, [data-pinnote-mask]/.pn-mask, href/src 쿼리를 가림. 전화번호·이메일 문자열도 가림. 서버에서 전화번호를 한 번 더 가림 |
| 파일 노출 | 설정·lib·storage·db·bin은 웹 루트 밖. 캡처는 shot.php의 서명 URL(7일) 또는 관리자 로그인으로만 볼 수 있음. 밑줄 파일·숨김 파일 차단 |
| 토큰 유출 | 해시만 저장, 원문은 한 번만 표시, 폐기 가능, 읽기 전용 토큰 |
| 관리 화면 | CSRF 이중 제출, 서명 쿠키, 로그인 제한, 보안 기록(activity_logs SECURITY) |
---
11. 한계와 다음 할 일
- 호스트 CSP: 설치할 사이트의 CSP
script-src·connect-src(·img-src)에 핀노트 주소가 있어야 한다. 뉴팅(imgx.kr)은 지금script-src 'self' 'nonce-…'; connect-src 'self'라서 설치 한 줄만으로는 막힌다. 뉴팅 쪽 CSP를 고쳐야 한다. - html2canvas
- 캡처하는 동안 글꼴 측정용 임시 요소를
document.body에 잠깐 붙였다 뗀다. - iframe·canvas·교차 출처 이미지는 빈칸이 될 수 있다.
- AMD(
define)가 있는 페이지에서는 전역에 올라가지 않아 캡처가 생략된다. - 엄격한 CSP 페이지에서 캡처가 되는지는 확인하지 않았다(의견 저장은 확인함).
- 보고자 식별: 브라우저·도메인별 익명 키라서 기기를 바꾸면 다른 사람으로 본다. 키가 유출되면 그 사람 행세를 할 수 있다(데모 수준).
- 다시 찾기: 같은 텍스트의 형제가 여러 개이고 구조까지 바뀌면 "못 찾음"으로 떨어진다(의도한 보수적 선택). 가상 스크롤처럼 화면 밖 요소를 DOM에서 빼는 목록은 방문 N번 기준으로 흡수한다.
- 다음 후보
- 서명 웹훅
- MCP 서버(REST 래핑)
- 이메일·슬랙 알림
- 버전별 라운드(Pastel식)
- 역할별 권한 세분화
- 캡처를 서버 렌더링으로 대체할지 검토
- 오래된 rate_limits·activity_logs 정리 크론
모달(<dialog>) 안에서 쓰기
showModal() 로 연 모달은 브라우저의 최상위 레이어에 떠서, z-index 를 아무리 높여도 그 위에 그릴 수 없다.
게다가 모달이 열려 있는 동안 바깥 화면은 눌리지 않는다(inert). 그래서 위젯 버튼조차 누를 수 없었다.
해결: 열린 모달 안으로 위젯(<pinnote-root>)을 옮긴다.
- 화면 변화 감시(MutationObserver)와 모드를 켤 때
followModal()이 열린dialog[open]을 찾아 그 안으로 옮기고, 닫히면 원래 자리로 되돌린다. - 모달이 열려 있는 동안에는 그 모달 안 요소만 의견 대상으로 삼는다(뒤쪽 화면은 어차피 누를 수 없다).
- 덕분에 모달이 열린 뒤에 핀노트를 켜고 끌 수 있고, 모달은 그대로 열려 있다.