Initial hop. implementation and UGREEN NAS deployment guide

This commit is contained in:
2026-10-12 13:37:50 +09:00
commit 439c2ae61b
93 changed files with 42434 additions and 0 deletions

123
docs/IMPLEMENTATION_PLAN.md Normal file
View File

@@ -0,0 +1,123 @@
# hop. 구현 설계와 검증 계획
기준: SPEC.md v3.0. 초기 분석 시 저장소는 명세만 포함했다. 이후 진행과 실제 검증 결과는 [VERIFICATION.md](VERIFICATION.md)에 기록한다.
사용자 추가 결정(2026-10-08): 배포 포트·접속 정보는 필요할 때 확인하며 샘플 포트를 고정하지 않는다. 익명 자료의 계정 저장은 기존 익명 접근권을 제거하는 이동이다. 이메일 발송은 기존 Resend 연동(`hello@noname.company`)을 사용한다. 아래 초기 SMTP 계획보다 이 사용자 지침을 우선한다. 신규 전송은 파일·URL만 지원하고 일반 텍스트·유형 탭은 제거하며 [통합 전송 수정안](TRANSFER_UX.md)을 우선한다.
## 1. 아키텍처
- `apps/web`: Vue 3 + JavaScript + Vite + Tailwind CSS. 기기 홈, 보내기 패널, 수신 요청 카드, 이번 기기, 계정 보관함, 설정, 관리자 화면. REST 재조회로 상태를 복구하며 Socket.IO 이벤트는 변경 알림으로 사용한다. 사용자 요청에 따라 프런트엔드부터 TypeScript를 제거했고, 공통 모듈과 서버는 후속 단계에서 전환한다.
- `apps/server`: Fastify + TypeScript. 설정, 인증, 발견, 연결 공간, 요청, 전송, 접근권, 저장소, 용량, 정리, 메일, 운영 모듈을 분리한다. HTTP와 Socket.IO는 동일한 인증·권한 서비스를 사용한다.
- `packages/shared`: 입력 검증 스키마, API DTO, 오류 코드, 상태와 이벤트 타입. 서버 비밀값이나 DB 내부 모델을 프런트엔드에 내보내지 않는다.
- SQLite + Drizzle: 버전 관리된 마이그레이션, 외래 키, WAL, busy timeout. 원자적 상태 전이와 용량 예약은 짧은 쓰기 트랜잭션으로 처리한다. 스트리밍이나 SMTP 호출 중 DB 트랜잭션을 유지하지 않는다.
- NAS 비공개 볼륨: 임시 업로드와 확정 파일 분리. 임의 저장 키만 사용하며 정적 파일 라우트로 노출하지 않는다.
- Docker Compose: 단일 앱 서버가 빌드된 웹과 API를 제공한다. DB와 파일 볼륨을 분리하고 Nginx Proxy Manager가 HTTPS를 종료한다. 배포 전 자동 마이그레이션을 수행한다.
- 초기에는 단일 프로세스의 메모리에서 활성 소켓·발견 그룹·속도 제한을 관리한다. DB가 요청과 전송의 기준이다. 재시작 시 모든 기기는 재연결 전까지 오프라인이다. 다중 프로세스 배포는 초기 범위에서 제외한다.
## 2. 인증과 권한 경계
익명 세션과 계정 세션은 서로 다른 HttpOnly 쿠키를 사용한다. 충분한 길이의 무작위 비밀값을 발급하고 DB에는 해시만 저장한다. 공개 device ID는 권한 증명이 아니다. 운영 쿠키에는 Secure와 SameSite=Lax를 적용한다.
익명 세션 하나에 브라우저 기기 하나가 연결된다. 여러 탭은 같은 세션을 공유하며 기기별 소켓 집합으로 온라인을 계산한다. 마지막 소켓 종료 뒤 짧은 유예 후 오프라인으로 처리한다. 초기 세션 발급은 여러 탭의 첫 방문 경쟁으로 중복 기기가 만들어지지 않도록 클라이언트에서 직렬화한다.
계정 로그인은 익명 기기를 교체하지 않는다. 자료 조회는 item_access를 기준으로 현재 주체의 권한·삭제·만료를 확인한다. 로그인만으로 익명 자료를 계정에 합치지 않는다. 계정 저장은 명시적 동작이며 원래 만료를 유지한다.
상태 변경에는 CSRF 토큰과 Origin 검증을 적용한다. Socket.IO 연결도 세션과 Origin을 확인하고 서버가 room을 결정한다. 권한 상실 시 연결 또는 해당 room을 제거한다. 콘텐츠·토큰·원본 IP·민감 URL은 로그와 이벤트에 포함하지 않는다.
같은 네트워크 탐색에는 직접 접속 IP 또는 명시적으로 신뢰한 프록시에서 검증한 IP만 사용한다. 정확한 주소별 HMAC 그룹을 접속 중 메모리에 유지한다. IPv6 /64 통합과 임의 X-Forwarded-For 신뢰는 금지한다. 배포 경로를 검증하지 못하면 탐색을 비활성화하고 QR·코드를 제공한다.
## 3. 데이터베이스
내부 ID는 UUID, 시간은 UTC로 저장한다. 다음 관계를 단계별 마이그레이션으로 추가한다.
| 영역 | 테이블 | 관계·주요 제약 |
| --------- | --------------------------------------- | ---------------------------------------------------------------------------------- |
| 익명 기기 | guest_sessions, browser_devices | 세션 해시 UNIQUE, 기기의 guest_session_id UNIQUE, 이름 길이·발견 설정, 만료 인덱스 |
| 공간 | connection_spaces, space_members | 생성 주체, QR·코드 해시, 만료; 공간/기기 UNIQUE, 승인·퇴장 상태 |
| 계정 | users, account_sessions, account_tokens | 정규화 이메일 UNIQUE, 세션/토큰 해시 UNIQUE, 역할·상태, 토큰 목적·만료·사용 시각 |
| 요청 | transfer_requests | local/email, 발신·수신 주체, 불변 메타데이터, 요청 상태, 승인·만료 시각 |
| 저장 | transfer_batches, items, files | 요청 참조, 전송 상태, 항목별 결과, 파일 키 UNIQUE, 실제 바이트·체크섬·임시 상태 |
| 접근권 | item_access | 항목/주체/역할 UNIQUE, 읽음·즐겨찾기·삭제·접근 만료 |
| 수신 정책 | user_rules | 계정 간 허용/차단 UNIQUE; 정책은 계정 설정에 저장 |
| 예약·삭제 | quota_reservations, deletion_operations | 주체·파일·예약 만료, 서버 복원 ID·5초 기한·삭제 대상 |
| 운영 | reports, system_settings, audit_events | 신고는 식별자·사유, 감사 기록은 최소 행위·결과·시간 |
명세에 필요한 보조 구조도 추가한다. 익명 차단 관계, 주체/작업/idempotency key별 요청 해시와 결과, 삭제 작업의 대상 접근권 관계를 별도 테이블로 관리한다. 이 구조는 익명 차단·중복 방지·다중 삭제 복원을 위한 내부 구현이다.
모든 주체 컬럼은 user_id와 guest_session_id 중 정확히 하나가 있는 CHECK를 적용한다. 요청의 수신 주체는 모드와 일치해야 한다. 목록에는 주체/삭제/생성 시각/ID 복합 인덱스를, 정리에는 상태/만료 인덱스를 둔다. 요청 메타데이터는 승인 후 수정할 수 없다.
사용자 탈퇴와 익명 초기화는 해당 접근권을 폐기한다. 상대방의 유효한 자료까지 연쇄 삭제하지 않는다. 발신자 식별 제거와 저장 원본 소유권·용량 정리는 별도 절차로 처리한다.
## 4. API 계약
명세의 경로를 유지한다. 추가로 상태 변경을 위한 CSRF 토큰 전달 계약과 익명 차단 동작을 정의한다. 입력은 공유 스키마로 검증하고 응답에는 저장 키·세션 해시·프록시 정보가 포함되지 않는다.
| API 영역 | 인증·권한 | 도입 단계 |
| --------------------------------------------------- | -------------------------------------------------- | --------------------- |
| guest/session, device/me, device/reset | 세션 발급 외에는 유효한 익명 세션 | 1 |
| discovery/devices, spaces 및 참여 승인·퇴장 | 익명 세션, 발견 범위 또는 생성자 권한 | 1 |
| transfer-requests 생성·조회·수락·거절·취소 | 해당 요청의 송신/수신 주체, 발견 범위 재검사 | 1, 이메일은 3 |
| transfers/:id/complete | 승인된 요청의 송신 주체, 불변 메타데이터·상태 검사 | 1, 파일은 2 |
| device/items, access 상세·read | 해당 익명 접근권; 상세와 사용 동작에서만 읽음 | 1 |
| uploads 생성·취소, download, preview | 예약과 요청에 결합한 업로드 권한 또는 자료 접근권 | 2 |
| delete-batch, deletion-operations/:id/undo | 본인 접근권·본인 삭제 작업·서버 유예 기한 | 2 |
| auth, account, inbox, vault, sent, favorites, rules | 인증 계정; 원격 사용에는 이메일 인증 필수 | 3, 세션·탈퇴 완성은 4 |
| access save-to-account, favorite | 익명 원본 접근권과 인증 계정 또는 계정 접근권 | 3 |
| resend, reports, admin | 본인 접근권/인증 계정/관리자 역할 | 4 |
| health | 공개 최소 상태; 내부 경로나 설정은 비공개 | 1 |
오류는 `{ code, message, requestId }` 형식이다. 목록은 `limit`과 불투명 `cursor`를 받으며 동일 시각 자료도 안정적으로 순회하도록 시각과 ID를 함께 정렬한다. read/favorite은 토글 대신 목표 boolean을 받는다.
요청 생성·수락·전송 확정은 주체와 작업에 결합한 Idempotency-Key를 사용한다. 같은 키/같은 내용은 같은 결과, 다른 내용은 409 충돌로 처리한다. 저장된 결과를 반환해도 현재 접근권을 확인한다.
Socket.IO는 명세의 device, space, request, transfer, inbox, access, account 이벤트를 제공한다. 이벤트는 서버가 범위를 검사한 세션에만 보낸다. 재연결과 탭 복귀에는 목록을 REST로 재조회한다. 파일 바이트는 HTTP 스트리밍으로 전달한다.
## 5. 요청·전송 상태
요청은 `pending → accepted/rejected/expired/cancelled`로 전이한다. 수락은 수신 주체만, 대기 요청 취소는 발신 주체만 가능하다. 중복 요청은 기존 결과를 반환하고 충돌 전이는 거절한다. 수락 시 온라인·발견 허용·범위·차단·만료·용량을 재검사한다.
전송은 `waiting → uploading → stored/failed`이며 항목별 상태를 별도로 둔다. 즉석 텍스트와 URL도 요청에는 유형·바이트 크기·개수만 보내고 본문은 수락 뒤 확정 API로 보낸다. URL은 개별 항목으로 저장한다. URL이나 텍스트 내용이 변경되면 새 요청을 만드는 클라이언트 흐름을 제공한다.
파일 요청은 불변 파일 목록에 결합한다. 수락과 예약 후 목록에 있는 파일만 업로드한다. 성공 파일은 유지하고 실패 파일만 같은 메타데이터로 다시 업로드한다. 실제 바이트와 선언 크기가 다르면 확정하지 않는다. 업로드 취소·실패·시간 초과는 예약과 임시 파일을 회수한다.
이메일 전송은 인증 계정 대상으로만 가능하다. 자기 주소와 허용 관계는 승인된 상태에서 시작한다. 낯선 발신자는 최대 7일 수락 대기 후 승인되면 24시간 내 업로드를 시작해야 한다. 오프라인 송신자의 로컬 파일은 서버에서 자동 전달할 수 없으므로 재선택을 안내한다. 시작·확정 시 수신 정책을 재검사한다.
## 6. 용량과 정리
용량 제한과 예약은 같은 DB 쓰기 트랜잭션에서 검사한다. 실제 파일은 원본 송신자의 논리 사용량에, 수신자 즐겨찾기는 해당 수신자의 논리 사용량에 반영한다. 자기 저장은 중복 합산하지 않는다. 익명 전체 상한·동시 업로드·최소 디스크 여유도 검사한다. MiB/KiB와 GiB는 이진 단위로 계산한다.
자료의 기본 만료는 확정 시각부터 텍스트·URL 30일, 파일 7일이다. 즐겨찾기는 해당 계정의 접근권만 보존한다. 읽음과 상대의 즐겨찾기는 본인 만료를 연장하지 않는다. 익명 세션이 먼저 만료되면 자료가 남아 있어도 이전 세션으로 접근하지 못한다.
삭제는 접근권 단위로 수행하고 5초 서버 유예 동안 복원 가능하다. 자연 만료는 복원하지 않는다. 유예 중 물리 정리를 보류한다. 마지막 유효 접근권·즐겨찾기·복원 유예·재전송 참조가 종료된 이후에만 원본을 정리한다.
주기 작업은 요청·공간·세션 만료, 예약 회수, 24시간 고아 업로드, 접근권·자료 만료, 파일 참조 및 용량 불일치, 30일 감사 기록을 처리한다. 작업은 중복 실행에 안전하게 만들고 실패를 운영 상태에 남긴다. DB 삭제와 파일 unlink의 실패는 재시도 가능한 정리 상태로 관리한다.
## 7. UI
홈은 hop., 내 기기 이름 편집, 발견 허용, 같은 네트워크 기기, QR·코드 연결, 통합 보내기 영역으로 구성한다. 첫 방문 모달·필수 이름 입력·권한 요청은 없다. 이름은 중복 가능한 비인증 표시임을 고려해 서버 구분 코드를 병기한다.
데스크톱은 기기 카드와 전송 패널, 모바일은 단일 열과 하단 기기/이번 기기/보관함 내비게이션을 사용한다. 조용한 수신 요청 카드에서 메타데이터만 표시하고 수락 뒤 콘텐츠를 제공한다. 자동 링크 열기·다운로드는 없다.
44px 이상의 터치 목표, 키보드·스크린리더 동작, 대비와 reduced motion을 지원한다. 스와이프 삭제는 버튼을 드러내는 동작이며 세로 스크롤과 버튼·텍스트 선택을 방해하지 않게 한다. 반응형 자동 테스트와 실기기 검증은 별도로 보고한다.
## 8. 단계별 구현과 완료 관문
| 단계 | 결과 | 빌드·테스트 관문 |
| ---- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | 모노레포·Docker·익명 세션·이름·발견·공간·온라인·텍스트/URL 요청과 이번 기기 | 타입 검사와 전체 빌드, 실제 SQLite API 통합 테스트, 두 브라우저 문맥 전송, 다중 탭·범위 격리·승인·퇴장·차단·타임아웃·중복·CSRF·Origin 테스트 |
| 2 | 스트리밍 파일·진행률·취소·인증 다운로드/래스터 미리보기·용량·정리·삭제/복원 | Phase 1 회귀, 수락 전 업로드 차단, 선언 크기 불일치·중단·경로 조작·동시 예약, 권한 없는 다운로드, 부분 실패 재시도, 5초 복원·만료·터치 동작 |
| 3 | SMTP 인증·로그인/복구·계정 보관함·수신 정책·오프라인 전송·계정 용량·익명 선택 저장 | 기존 단계 회귀, 테스트 SMTP 메일과 일회용 토큰, 세션 회전·재설정 무효화, 미인증 차단·허용/차단 우선순위·계정 격리·다중 기기 동기화 |
| 4 | PWA·공유 초안·SSRF 방어 미리보기·테마·비활성 광고 슬롯·재전송·세션/탈퇴·신고/관리자·운영 문서 | 전체 빌드·회귀, private API 캐시 제외, 계정 전환, DNS/redirect SSRF 차단, 광고 미활성, 관리자 권한, 탈퇴 후 상대 자료 보존 |
| 5 | 보안·복구·모바일·배포 검증과 완료 기준별 증거 | 공격성 통합 테스트, 실제 프록시 경로 검증, Docker 재시작·볼륨 보존·일관 백업/복원, 모바일/PWA 실기기 확인 |
각 단계에서 코드·마이그레이션·문서·환경 예시를 갱신하고 테스트 명령과 결과를 기록한다. 이전 단계의 관문을 통과한 후 다음 단계로 진행한다. 테스트에만 쓰는 fixture와 실제 구현 완료 증거를 구분한다. 사용할 수 없는 Docker daemon이나 실기기는 검증하지 않은 항목으로 명시한다.
## 9. 구현 전 확인 사항
Phase 1 배포 IP 신뢰 정책을 위해 실제 접속 경로 확인이 필요하다. 인터넷 → Nginx Proxy Manager → hop.인지, Cloudflare나 추가 프록시가 있는지 확인한다. 신뢰할 프록시 IP/CIDR은 환경 설정으로 명시한다. 검증 전 운영 환경의 같은 네트워크 탐색은 끈다.
이후 단계에서는 실제 SMTP·최초 관리자 비밀값·운영 비밀값을 코드나 Git에 넣지 않고 운영자가 주입한다. 개발 검증에는 격리된 SMTP를 사용한다. 광고 사업자 연동과 공개 활성화는 해당 시점의 정책 검토 뒤 진행한다.
## 2026-10-12 구조 수정
최신 기기 중심 화면, 로그인 수신 기록, 7일 임시 보관함, 메모와 저장 자료 전달, 단일 방 격리는 [TEMPORARY_VAULT.md](TEMPORARY_VAULT.md)를 기준으로 구현했다. 외부 이메일 원격 전송과 영구 즐겨찾기, 익명 사후 가져오기는 이전 계획에서 제외한다. 신규 보관함 서비스·테스트 코드는 JavaScript이며 서버의 기존 TypeScript는 후속 전환 단계까지 유지한다.

43
docs/TEMPORARY_VAULT.md Normal file
View File

@@ -0,0 +1,43 @@
# hop. 기기 전송·임시 보관함 기준
2026-10-12 사용자 수정안을 반영한 현재 기준이다. 이전 SPEC의 이메일 원격 전송, 익명 전송 내역 화면, 영구 즐겨찾기, 여러 방 동시 참여 및 좌우 분할 화면보다 우선한다.
## 화면과 전송
메인은 중앙의 기기 선택 화면이다. 헤더는 홈으로 돌아가는 로고, 라이트/다크 토글, 로그인/보관함 버튼만 제공한다. 기기 목록은 최대 데스크톱 4열, 태블릿 3열, 모바일 2열이며 적은 기기는 가운데에 놓는다. 최대 화면 너비는 1080px이다. 내 기기는 자연스럽게 스크롤되는 푸터의 작은 상태 버튼으로 표시하고, 이름과 발견 허용을 그 버튼의 설정에서 변경한다. 발견 허용/꺼짐은 푸터에서 항상 확인할 수 있다. QR 연결은 기기 카드와 같은 영역의 보조 카드로 둔다. 헤더 로고와 짧은 보조 문구는 작게 표시하고 푸터의 서버 경유 안내는 제거한다.
기기를 누르면 PC 중앙 모달·모바일 하단 시트에서 현재 콘텐츠 하나를 보낸다. 클립보드 링크가 있으면 링크가 기본이며, 실제로 파일을 선택하면 링크를 대체한다. 링크 입력으로 전환했을 때 클립보드가 비어 있으면 빈 URL 입력과 클립보드 가져오기를 제공한다. 링크·파일·보관함 자료를 함께 보내지 않으며 전송/취소 버튼은 언제나 하나다. 선택 방법을 바꿔도 모달의 너비·높이를 유지하고 내용 영역 안에서만 스크롤한다. 기기 선택 전에는 전송 입력을 표시하지 않는다. 클립보드의 유효한 HTTP/HTTPS 링크만 제안하며 읽는 것만으로 전송하지 않는다. 다시 선택하거나 페이지로 돌아오면 최신 클립보드를 읽는다. 권한 제한 시 직접 새로고침·붙여넣기를 제공한다. 수동 편집 중인 URL은 자동 갱신으로 덮어쓰지 않는다. 파일 선택은 클립보드 실패와 독립적이다.
전송 버튼 자리에서 취소와 진행률을 표시한다. 모달을 닫는 동작은 진행 중 요청을 취소한다. 완료하면 선택 화면으로 돌아온다. 수신자는 중앙의 흰색 수락/거절 카드로 결정한다. 수락한 탭에서 URL을 바로 열고 파일을 다운로드한다. 정상 완료에는 도착 확인이나 두 번째 열기 팝업이 없다. 새 탭이 차단되거나 준비한 탭을 닫은 경우에만 수동 열기 안내를 제공한다. 재조회와 다른 탭에서는 과거 자료를 자동 실행하지 않는다.
## 받은 항목과 임시 저장
비로그인 방문자에게 전송 내역을 보여주지 않는다. 로그인한 계정이 수락하고 그 세션이 전달 완료까지 유효한 경우 받은 항목에 기록한다. 로그인 전 받은 자료는 가져오지 않는다. 받은 항목은 날짜별로 묶는다. 자동 열기/다운로드 후 읽음은 기기 접근권과 계정 기록에 함께 반영한다.
오른쪽 보관함 버튼 안에서 `임시 저장 / 받은 항목`을 전환한다. 임시 저장은 자신이 링크·파일을 담는 공간이다. 먼저 `링크 저장 / 파일 저장` 중 하나를 선택하고 해당 입력만 표시한다. 선택적인 한 줄 제목/메모는 최대 200자이며 원래 만료를 연장하지 않고 수정할 수 있다. 이메일 주소로 다른 사람에게 보내는 새 요청은 제거했다. Resend는 가입·인증·비밀번호 복구 등 계정 메일에 계속 사용한다.
기기 전송은 새 링크, 새 파일, 로그인한 사용자의 보관함 자료 중에서 보낸다. 보관함은 임시 저장과 받은 항목을 포함한 세 번째 콘텐츠 선택 방법이며 비로그인 화면에서는 숨긴다. 항목 선택은 미리보기만 바꾸고 한 개의 전송 버튼으로 확정한다. 저장한 자료도 대상 기기가 수락한 뒤에 전달한다. 저장된 파일은 서버의 원본을 재사용해 브라우저 재다운로드·재업로드와 중복 물리 저장을 피한다. 원본의 개인 메모는 상대에게 복제하지 않는다. 다른 계정의 자료, 만료·삭제한 원본, 폐기된 발신 계정 세션으로는 보낼 수 없다.
받은 항목과 임시 저장은 각각 저장 시점부터 최대 7일이다. 기존 자료도 마이그레이션 시점부터 최대 7일로 제한하며, 더 가까운 기존 만료는 유지한다. 즐겨찾기로 만료를 우회할 수 없다. 만료 항목과 접근권을 정리하고 마지막 유효한 파일 참조가 사라지면 원본 파일을 제거한다. 새 전송의 수신 기록은 새 저장 시점부터 7일이며 원본 기록의 만료를 연장하지 않는다.
## 주변 발견과 방
주변 기기는 검증된 접속 IP 그룹으로 찾는다. 운영에서는 신뢰할 프록시 경로 검증 후 같은 네트워크 발견을 켠다. 로컬 개발에서는 실제 loopback 접속을 같은 기기의 다른 브라우저로 인식해 자동 발견을 제공한다. 이 방식은 Bluetooth나 물리적 거리 측정이 아니다.
한 기기는 승인 대기를 포함해 한 방에만 속한다. 방 안의 기기는 주변 목록에서 사라지고 그 방에서 승인된 기기끼리만 발견·전송한다. 주변과 방의 목록을 섞지 않는다. 참여하면 초대/참여 모달을 자동으로 닫고, 참여 중에는 새 방 생성/참여 동작을 제공하지 않는다. 상태 아래에는 빨간 `방 나가기`만 표시한다. 방장이 나가면 방을 닫고 모두 방에서 나온다. 나가기·내보내기·방 종료로 진행 중인 해당 전송도 취소한다.
## 데이터와 API
기존 요청·묶음·항목·접근권·파일·업로드·인증 구조를 재사용한다. `request_receipt_accounts`는 기기 수락 시 계정과 인증 세션을 묶고, `vault_transfers`는 기기 요청과 저장한 원본 접근권을 연결한다. `item_access.note`는 개인 메모이고 `transfer_requests.note`는 업로드 완료 시 메모를 저장하기 위한 초안이다. 파일은 원본 ID를 공유하고 접근권은 새 전송별로 생성한다.
- `GET /api/inbox`: 로그인 중 수신 기록.
- `GET /api/vault`: 자신의 임시 저장 목록, 검색·페이지네이션.
- `GET /api/vault/contents`: 직접 저장·받은 항목 중 전송할 수 있는 본인 자료. 보관함 원본 조회는 계정 전환과 겹친 이전 응답을 화면에 반영하지 않는다.
- `POST /api/vault`: 링크 또는 파일 저장 요청과 선택적 메모. 링크는 즉시 확정하고 파일은 기존 인증 업로드 API를 사용한다.
- `PATCH /api/vault/:accessId/note`: 개인 메모 수정.
- `POST /api/vault/:accessId/send`: 선택한 기기에 기존 자료 전송 요청.
- 기존 기기 요청의 수락/거절/취소와 파일 다운로드 API는 유지한다. 새 이메일 원격 전송, 영구 즐겨찾기와 익명 자료의 사후 계정 이동은 410으로 거절한다.
마이그레이션 0007–0011은 계정 수신 연결·개인 메모·7일 제한·단일 방 정리·활성 참여의 DB 유일 제약을 추가한다. 기존 다중 참여는 가장 최근 참여 하나만 남기고 방장이 없는 방은 닫는다. 원래 마이그레이션 기록·자료 ID·상대 접근권은 보존한다. 직접 저장의 기존 내부 self 요청은 기존 저장 흐름과 마이그레이션 호환성을 위해 내부 `email` 모드를 재사용하며, 외부 이메일 전송을 다시 열지는 않는다.
개발은 자동 발견과 반복 테스트용 요청 한도를 사용하고 포트는 OS가 배정한다. 운영의 실제 포트·바인딩·프록시 설정은 배포 때 사용자에게 확인한다.

37
docs/TRANSFER_UX.md Normal file
View File

@@ -0,0 +1,37 @@
# hop. 파일·링크 통합 전송 수정안
2026-10-08 및 2026-10-12 사용자 수정안. SPEC.md v3.0의 일반 텍스트 전송과 유형 탭에 관한 요구보다 이 문서를 우선한다.
2026-10-12 최종 중앙 기기 선택·전송 모달·보관함·단일 방 정책은 [TEMPORARY_VAULT.md](TEMPORARY_VAULT.md)를 우선한다. 기기 전송은 클립보드 링크·선택한 파일·로그인 보관함 중 현재 콘텐츠 하나와 전송 버튼 하나만 표시한다. 전송 모달 크기는 콘텐츠 선택 방법에 따라 바뀌지 않는다. 보관함 직접 저장은 링크/파일 중 하나를 먼저 고른다.
## 지원 범위
신규 전송은 파일과 HTTP/HTTPS URL만 지원한다. 일반 텍스트 전송 UI·요청·확정 처리는 제거한다. 신규 API는 `kind: text`와 텍스트 본문을 거절한다. 이전 DB의 저장 자료는 삭제하지 않고 보존·정리 정책을 유지하며, 진행 중이던 텍스트 요청은 후속 마이그레이션으로 취소한다. 현재 목록은 파일과 URL만 표시한다.
## 기기 선택과 통합 패널
접속 기기를 누르면 해당 기기로 보내는 패널을 표시한다. 파일·링크 탭은 제공하지 않는다. 파일 선택은 항상 가능하며 클릭, PC 드래그 앤 드롭, 브라우저가 제공하는 모바일 사진·파일 선택을 사용한다. 선택한 파일의 이름·크기를 확인한 뒤 파일 전송을 누른다.
기기 선택 시 `navigator.clipboard.readText()`를 시도한다. HTTP/HTTPS URL만 링크 카드에 표시하고 사용자가 ‘링크 전송’을 누른 뒤 요청한다. 읽기 자체로 전송하지 않는다. 빈 클립보드·일반 텍스트·권한 거부·미지원은 파일 선택을 막거나 오류 팝업을 띄우지 않는다. ‘링크 직접 붙여넣기’는 작은 보조 동작으로 제공한다. 대상이 바뀌면 이전 링크·파일 선택을 지우며, 늦게 도착한 이전 대상의 클립보드 응답은 무시한다. 클립보드를 백그라운드에서 감시하지 않는다.
여러 링크는 한 줄에 하나씩 최대 20개를 붙여넣을 수 있다. URL 검증에 실패하면 링크를 전송하지 않으며 파일 선택은 계속 가능하다. 이메일 전송도 같은 파일·링크 컴포넌트를 사용하며 이메일 입력 중에는 클립보드를 자동 읽지 않는다.
## 수락과 완료
기존 수락 전 데이터 미업로드 정책을 유지한다. 수락·거절 요청은 어두운 배경을 흐리게 처리한 중앙의 흰색 카드로 표시한다. 수락 후 링크는 바로 새 탭으로 열고 파일은 다운로드를 시작한다. 정상 수신에는 ‘자료가 도착했어요’, ‘자료 보기’, 별도의 ‘링크 열기’ 확인을 표시하지 않는다.
링크용 탭은 수락 클릭 시 준비하고 실제 URL은 수락 후 전달 완료 시 연결한다. 새 탭의 opener는 제거한다. 브라우저가 탭을 막거나 사용자가 준비 중인 탭을 닫은 경우에만 클릭으로 열 수 있는 예외 안내를 제공한다. 브라우저의 여러 파일 다운로드 허용 설정은 그대로 따른다. 수락한 탭만 자동 실행하며 새로고침·다른 탭의 상태 조회로 과거 자료를 다시 열거나 다운로드하지 않는다. 오프라인 계정 보관함의 과거 자료는 기존 목록에서 확인한다.
보낸 링크·파일의 전송 버튼은 같은 자리에서 ‘전송 취소’로 변경한다. 대기 상태와 파일 업로드 진행률도 통합 패널 안에 표시하고 별도 하단 전송 목록은 제공하지 않는다. 파일 취소는 서버 요청 취소 후 해당 업로드를 중단한다.
비로그인 방문자에게는 ‘이번 기기’ 내역 메뉴·목록을 표시하지 않는다. 로그인 계정의 보관함과 익명 자료를 계정으로 이동하는 기능은 유지한다. 서버의 진행 중 전송 상태·권한·임시 저장 및 기존 만료 정리는 유지하며 이 화면 변경이 DB의 즉시 삭제를 뜻하지 않는다.
조회 중 들어오는 실시간 이벤트는 후속 조회에 반영한다. 기기·요청·자료 상태는 인증된 단일 snapshot API에서 같은 시점에 읽으며, 과도한 중복 요청으로 완료 상태가 갱신되지 않는 문제를 방지한다. 재연결은 API로 재동기화하며 개발 서버의 CSRF 비밀값 변경 시 세션을 다시 확인한다.
## 테마
기본은 순수 검정 배경, 중립 회색 표면·테두리, 흰색 주요 버튼이다. 기존 자동 시스템 기본값은 검정 기본값으로 전환하며 사용자가 선택한 라이트·다크는 보존한다. 라이트·시스템 테마도 같은 색상 변수로 전환한다.
## 후속 모바일 공유
Android PWA Web Share Target의 파일·URL 초안 수신은 2차 구현에서 검토한다. 공유받은 URL을 초안으로 표시하고 대상 선택과 사용자의 전송 동작을 거친다. iOS 등 미지원 환경에서는 일반 웹 붙여넣기·파일 선택을 제공한다. 자동 전송은 추가하지 않는다.

80
docs/VERIFICATION.md Normal file
View File

@@ -0,0 +1,80 @@
# 검증 기록
현재 적용 범위는 [파일·링크 통합 수정안](TRANSFER_UX.md)이다. 아래 초기 단계의 텍스트 관련 기록은 수정 이전 검증 이력이다.
## Phase 1 — 2026-10-08
- `npm run build`, `npm run typecheck`: 성공.
- `npm test`: 실제 SQLite·Socket.IO 통합 테스트 21개 성공.
- `npm run test:e2e`: 격리된 두 Chromium 문맥에서 코드 연결, 수락 전 본문 비공개, 실제 텍스트 전달, HTML 비실행, 읽음 동기화, 이름 재접속 유지, 다중 탭, 모바일 폭 390px 가로 넘침 없음 확인.
- `docker build -t hop-phase1-check:local .`: 성공. 운영 이미지의 워크스페이스 내부 의존성 누락을 발견하여 수정 후 재빌드.
- `node tests/docker/persistence.mjs`: 운영 모드 시작, Secure 쿠키, 마이그레이션, 재시작 후 세션·이름 보존, SQLite integrity check 성공. 고유 임시 컨테이너/볼륨을 생성하고 검증 후 제거. 호스트 포트는 공개하지 않음.
- 운영 의존성 audit: 알려진 취약점 없음. Docker 빌드용 개발 의존성에서 기존 esbuild-loader 경고가 남아 있으며 운영 이미지에서는 제거됨.
실제 NAS/Nginx Proxy Manager/Cloudflare 접속 경로, 실제 스마트폰·PWA 설치는 아직 검증하지 않았다. 앱 내 브라우저 연결이 없어 별도 자동 테스트용 Chromium을 사용했다. 포트와 운영 프록시 신뢰 범위는 배포 전에 사용자에게 확인한다.
## Phase 2 — 2026-10-08
- 전체 빌드 성공. Phase 1 회귀 포함 API·Socket.IO·실제 HTTP 업로드 테스트 32개 성공.
- 수락 전 업로드 금지, 실제 파일 다운로드와 권한 격리, 메타데이터 불일치·부분 실패 재시도, 원자적 전체/개별 용량 예약, 이미지 미리보기·SVG 차단, 5초 복원·마지막 접근권 정리, 업로드 확정 중복·payload 충돌 확인.
- 실제 HTTP 업로드를 중단하여 임시 파일·예약 회수 확인.
- 두 Chromium 문맥에서 실제 파일 전달·다운로드, 모바일 크기에서 합성 터치 포인터로 가로/세로 의도 구분·밀기만으로 삭제되지 않음·삭제 버튼·실행 취소·서버 실패 선택 복구 확인. 실물 스마트폰 제스처 확인은 별도 필요.
- Phase 1 본문과 접근권이 있는 DB를 Phase 2로 마이그레이션하여 데이터와 외래 키 유지 확인. SQLite 테이블 재구성은 전체 마이그레이션 트랜잭션 안에서 참조 검사를 통과해야 커밋함.
- `docker build -t hop-phase2-check:local .`, `HOP_CHECK_IMAGE=hop-phase2-check:local node tests/docker/persistence.mjs`: 운영 모드와 실제 파일 업로드 후 재시작·다운로드 보존 성공. 호스트 포트 미공개.
사용자 결정: Phase 3 계정 저장은 기존 익명 접근권을 제거하는 이동 방식. 원래 만료와 상대 접근권은 유지.
## Phase 3 — 진행 중, 2026-10-08
- 기존 단계 회귀 포함 DB·API 테스트 44개 성공. Argon2id, 인증 전 권한 차단, 토큰 해시·일회용, 로그인 회전, 비밀번호 재설정 세션 폐기, 낯선 발신자 수락, 허용 발신자의 오프라인 전달, 자기 보관함, 차단 우선, 계정 파일 용량·다운로드, 익명 접근권의 계정 이동과 파일 소유 용량 이동 확인.
- 실제 Chromium 두 문맥에서 가입·토큰 인증·로그인·원격 요청 수락과 실제 전달·읽음 동기화·즐겨찾기·삭제/복원·자기 보관함·로그아웃 화면 정리 확인. 메일은 테스트 내 수집 어댑터를 사용하며 실제 Resend 발송 성공을 뜻하지 않음.
- 전체 빌드·타입 검사 성공. 계정 확장을 적용한 뒤 Phase 1·2 API 회귀도 유지.
- 실제 Resend 발송 어댑터는 사용자 기존 코드 위치 또는 API 규격 확인을 기다림. 이 연결 전 Phase 3 완료로 처리하지 않으며 Phase 4로 넘어가지 않음.
- 현재 개발 QR의 loopback 주소는 휴대폰에서 PC로 접속할 수 없으므로 UI와 README에 안내. 개발 포트는 OS가 배정하고 서버 자동 재시작 중에는 유지. 실제 LAN·공개 HTTPS 검증은 미실시.
개발 DB에서 이전 계정 마이그레이션이 이미 적용된 버전 차이를 확인했다. 정확한 기존 버전만 후속 0005 마이그레이션으로 보정하고 원래 마이그레이션 기록을 유지한다. 실제 개발 DB의 integrity check `ok`, 외래 키 오류 0, 서버 정상 시작과 고정된 임시 포트에서 재시작을 확인했다. 동일 상태를 재현한 보존 테스트를 추가하여 44개 테스트 모두 통과했다. 데이터·캐시 폴더는 서버 코드 감시에서 제외하여 불필요한 재시작을 방지했다.
최종 현재 코드의 Docker 이미지 `hop-phase3-check:local` 빌드 및 익명 세션·이름·실제 파일 업로드/다운로드의 재시작 보존 검증도 성공했다. 이는 계정 Resend 발송 검증은 포함하지 않는다. `npm audit --omit=dev` 결과 운영 의존성의 알려진 취약점 0건. 로컬 Docker는 개발 Mac의 ARM64 환경이며 실제 AMD64 NAS·프록시 경로 검증은 배포 단계에서 별도 수행한다.
## 파일·URL 통합 UX 수정 — 2026-10-08
- 일반 텍스트 신규 요청·확정을 제거하고 API 거절 테스트 추가. 파일·URL만 현재 목록과 요청에 표시. 기존 저장 DB 기록은 보존하며 진행 중 텍스트 요청은 0006 마이그레이션으로 취소.
- 파일·링크 유형 탭 제거. 기기 선택 시 클립보드 URL 제안, 권한 거부·일반 텍스트 무시, 파일 선택·드래그 앤 드롭 유지, 링크 직접 붙여넣기 확인. 읽기만으로 전송 요청이 생성되지 않는 것을 실제 브라우저에서 확인.
- 지연된 과거 snapshot 응답이 수락 이벤트와 겹치는 경우를 재현하여 후속 조회와 실제 전달 성공 확인. 단일 snapshot API로 상태를 일관되게 읽고 중복 조회가 요청 제한을 소진하는 문제 수정.
- 수신·받는 중·도착 카드를 뷰포트 상단에 고정. 자료 보기는 해당 묶음으로 이동. 하단 삭제 실행 취소와 겹치지 않음 확인.
- 기본 검정·중립 회색·흰색 버튼, 라이트·시스템 테마 및 이전 기본 설정 전환 확인. 네이티브 업로드 진행률도 중립 색상으로 표시.
- `npm run build`, `npm run typecheck`, `npm test` 50개 성공. 실제 브라우저 즉석 전송과 계정 전송 회귀 성공. 클립보드 거부 상태의 실제 파일 드래그 업로드·다운로드, 복수 URL 전달, 재연결·CSRF 재확인 후 기기 이름 변경 확인.
- `docker build -t hop-ux-check:local .`, `HOP_CHECK_IMAGE=hop-ux-check:local node tests/docker/persistence.mjs`: 운영 모드·마이그레이션·재시작 후 익명 세션/이름/파일 보존 성공. 호스트 포트 미공개.
- Resend HTTPS 발송 어댑터와 실패/중복 요청 계약 테스트 완료. `.env`의 키 설정 여부만 확인했으며 실제 키를 출력하지 않음. 실제 수신 검증은 사용자 테스트 주소 확인을 기다리고 있고, 실제 이메일 발송 성공으로 주장하지 않음.
- Android PWA Web Share Target는 사용자 지정 2차 구현 범위. 실물 스마트폰 클립보드·공유 메뉴 검증은 아직 하지 않음.
## 기기 카드·링크 열기·JavaScript 전환 1단계 — 2026-10-12
- 중복 참여자 목록을 제거하고 큰 기기 카드에 HOST 표시와 방장용 더보기 메뉴를 통합. 승인·빨간색 내보내기, 메뉴 외부 클릭·Escape 닫기, 양쪽 브라우저의 퇴출 반영 및 모바일 화면 검증 성공.
- 수신 URL을 직접 클릭하는 네이티브 새 탭 링크로 변경. 도착 알림과 익명·계정 목록에서 실제 목적지 새 탭 열기 및 opener 격리 검증 성공.
- Vue 컴포넌트는 유지하고 모든 프런트엔드 script setup, 클라이언트·테마·조회 큐·진입점·Vite 설정을 JavaScript로 전환. 웹 tsconfig와 vue-tsc, @types/qrcode 직접 의존성 제거. 서버와 공통 모듈의 TypeScript는 다음 전환 단계까지 유지.
- 전체 빌드와 남은 서버·공통 모듈 타입 검사 성공. 실제 로컬 서버 실행 권한으로 API 회귀 테스트 50개 성공. 익명 전송·계정 전송·기기 메뉴의 Chromium 자동 테스트 3개 스위트 성공.
- 실제 Resend 수신, 실물 모바일 기기, 운영 NAS 배포는 이번 검증에 포함하지 않음.
## 수락 후 자동 실행·같은 자리 취소·개발 계정 — 2026-10-12
- 수락 클릭 시 링크 탭을 준비하고 전달 완료 시 실제 URL로 이동. 파일은 수락한 탭에서 자동 다운로드하며 실제 파일 내용 일치 확인. 도착 안내와 추가 열기 확인 제거. 탭 차단 시에만 수동 열기 예외 안내 검증.
- 한 개·여러 URL의 실제 자동 새 탭 이동, opener 제거, 읽음 동기화, 새로고침 후 과거 자료 재실행 없음 확인. 지연된 과거 조회와 수락 이벤트가 겹치는 회귀도 유지.
- 비로그인 이번 기기 메뉴·목록 제거. 전송 버튼 자리에서 링크·파일 요청 취소, 업로드 시작 후 취소 확인. 서버 취소 상태가 API 응답보다 먼저 도착하는 경우에도 의도한 취소를 업로드 오류로 표시하지 않도록 수정.
- 수락 팝업의 흰색 배경과 뷰포트 중앙 위치를 모바일에서 검사하고 화면 이미지 확인. 검정·라이트 테마, 기기 관리와 계정 보관함 기능 유지.
- 개발·테스트 비밀번호 최소 1자, 운영 최소 12자 적용. 서버가 제공하는 최소 길이에 맞춰 가입·재설정 화면 갱신. 운영에서 짧은 새 비밀번호 거절 테스트 성공.
- 실제 개발 DB에 일반 사용자 test1@hop.test, test2@hop.test 생성. 둘 다 1234로 로그인 검증, active·인증 완료 상태 확인. 실제 메일은 발송하지 않음. DB integrity ok, 외래 키 오류 0.
- 전체 빌드·남은 타입 검사·API 테스트 51개 성공. 익명 전송·1234를 사용하는 실제 가입/인증/로그인과 계정 전송·기기 메뉴 Chromium 스위트 성공. 실제 Resend 수신·실물 모바일의 브라우저별 자동 다운로드 권한은 별도 검증 필요.
## 기기 중심 단일 화면·단일 콘텐츠·7일 보관함 — 2026-10-12
- 메인은 기기 카드와 QR 카드를 함께 보여주는 중앙 화면. 내 기기의 이름·발견 상태·설정은 푸터로 이동. 자연스러운 스크롤과 짧은 화면에서 하단 배치, 작은 헤더 로고·보조 문구, 푸터의 서버 경유 문구 제거.
- 실제 12개 Socket.IO 기기와 브라우저를 연결해 PC 4열·태블릿 3열·모바일 2열과 줄바꿈 검증. 선택 전 전송 입력 없음, 선택 후 PC 중앙 모달·모바일 하단 시트, Escape와 포커스 순환 확인. 화면 이미지 직접 확인.
- 클립보드 URL이 기본 콘텐츠이고 실제 파일 선택이 URL을 대체. 파일/URL/로그인 보관함 메뉴를 바꿔도 모달의 너비·높이가 동일하고 콘텐츠 내부만 스크롤. 전송/취소 버튼은 한 개. 빈 클립보드·직접 URL 입력, 페이지 복귀·재선택·가져오기 갱신, 드래그 업로드, 브라우저 차단 예외 확인.
- 보관함 선택은 미리보기만 변경하며 단일 전송 버튼에서 요청 생성. 직접 저장과 받은 항목의 본인 자료를 전송 소스로 제공. 실제 저장 파일 재업로드 없이 수락 후 원본 다운로드 바이트 일치 검증.
- 로그인 중 수신만 계정에 날짜별 기록. 로그인 전 자료 사후 가져오기 제거, logout 전후 세션 경계 확인. 직접 저장의 메모·수정·검색, 7일 만료·실제 항목 삭제와 마지막 원본 참조 정리, 영구 즐겨찾기 우회 제거.
- 한 기기 한 방의 DB 유일 제약, 승인 대기도 주변에서 격리, 방 생성/다른 방 참여 거절, QR 참여 시 초대·참여 모달 자동 닫기, 방장 나가기의 전체 종료·진행 중 전송 취소, 주변 복귀 확인.
- 마이그레이션 보존·7일 상한 테스트 성공. 실제 개발 DB 백업 후 마이그레이션 확인: integrity ok, 외래 키 오류 0, 기존 테스트 계정 2개 유지.
- 전체 빌드·남은 서버/공통 모듈 타입 검사 성공. API 회귀 59개 성공. 익명 전송·계정/보관함·기기 관리·레이아웃의 Chromium 스위트 성공.
- `hop-temporary-vault-check:local` 운영 이미지 빌드와 격리 컨테이너의 마이그레이션·Secure 쿠키·세션/이름/실제 파일 재시작 보존 검증. 호스트 포트는 공개하지 않음.
- 실제 Resend 수신, 실물 iOS/Android의 브라우저별 권한, 운영 NAS·프록시·실제 LAN 경로는 이번 자동 검증에 포함하지 않음.