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

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
**/node_modules
**/dist
.data
.env
.env.*
!.env.example
.git
test-results
playwright-report
tests/e2e/*.png
compose.nas.yaml
backups

44
.env.example Normal file
View File

@@ -0,0 +1,44 @@
# 실제 배포 직전에 충돌 없는 포트·바인딩 IP·프록시 경로를 확인해서 입력합니다.
HOP_BIND_IP=
HOP_HOST_PORT=
HOP_CONTAINER_PORT=
APP_URL=https://hop.noname.company
NODE_ENV=production
TZ=Asia/Seoul
SESSION_SECRET=
DISCOVERY_SECRET=
DATABASE_PATH=/data/database/hop.sqlite
UPLOAD_DIR=/data/uploads
# 실제 접속 IP 전달을 검증한 후에만 활성화합니다.
SAME_NETWORK_DISCOVERY_ENABLED=false
TRUSTED_PROXY_CIDRS=
GUEST_SESSION_RETENTION_DAYS=30
LOCAL_REQUEST_TIMEOUT_SECONDS=60
ACCEPTED_UPLOAD_START_HOURS=24
SPACE_RETENTION_MINUTES=10
SPACE_MAX_DEVICES=10
OFFLINE_GRACE_MS=3000
MAX_URL_PAYLOAD_SIZE_KB=1024
MAX_ITEMS_PER_TRANSFER=20
DEFAULT_URL_RETENTION_DAYS=7
GUEST_REQUESTS_PER_MINUTE=5
IP_REQUESTS_PER_MINUTE=20
PAIR_REQUESTS_PER_MINUTE=2
REJECT_COOLDOWN_SECONDS=300
# Resend 발송 설정과 실제 인증 메일 수신을 확인한 뒤 공개 가입을 엽니다.
REGISTRATION_MODE=closed
EMAIL_VERIFICATION_REQUIRED=true
# Resend에서 noname.company 도메인 Verified와 Sending access 권한을 확인합니다.
RESEND_API_KEY=
MAIL_FROM=hop. <hello@noname.company>
SETUP_TOKEN=
MAX_FILE_SIZE_MB=2048
USER_STORAGE_LIMIT_MB=10240
GUEST_STORAGE_LIMIT_MB=2048
TOTAL_GUEST_STORAGE_LIMIT_MB=20480
MIN_FREE_DISK_MB=5120
DEFAULT_FILE_RETENTION_DAYS=7
EMAIL_REQUEST_RETENTION_DAYS=7
ORPHAN_UPLOAD_RETENTION_HOURS=24
DELETE_UNDO_SECONDS=5
AUDIT_RETENTION_DAYS=30

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
node_modules/
dist/
.env
.env.*
!.env.example
.data/
*.sqlite*
playwright-report/
test-results/
.DS_Store
tests/e2e/*.png
compose.nas.yaml
backups/

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
package-lock.json
SPEC.md
apps/server/drizzle/meta

1
.prettierrc.json Normal file
View File

@@ -0,0 +1 @@
{"singleQuote":true,"trailingComma":"all","printWidth":100,"semi":true}

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production HOST=0.0.0.0 WEB_ROOT=/app/apps/web/dist DATABASE_PATH=/data/database/hop.sqlite UPLOAD_DIR=/data/uploads
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/packages ./packages
COPY --from=build --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
COPY --from=build --chown=node:node /app/apps/server/package.json ./apps/server/package.json
COPY --from=build --chown=node:node /app/apps/server/dist ./apps/server/dist
COPY --from=build --chown=node:node /app/apps/server/drizzle ./apps/server/drizzle
COPY --from=build --chown=node:node /app/apps/web/dist ./apps/web/dist
RUN mkdir -p /data/database /data/uploads && chown -R node:node /data
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "apps/server/dist/main.js"]

92
README.md Normal file
View File

@@ -0,0 +1,92 @@
# hop.
기기 사이를 가볍게. 가입 없이 접속한 브라우저 사이에서 수락 후 자료를 전달하는 서비스입니다.
현재 **Phase 1·2 검증 완료 · Phase 3 구현 진행 중**입니다. 파일 전송은 구현되어 있습니다. 로그인 수신 기록·7일 보관함은 구현되어 있으며 Resend API 어댑터의 실제 메일 수신 검증이 남아 있습니다. 관리자·PWA는 후속 단계입니다. 전체 설계는 [구현 계획](docs/IMPLEMENTATION_PLAN.md), 개발 기준은 [SPEC.md](SPEC.md)를 참고하세요.
## 로컬 개발
Node.js 22.12 이상과 npm을 사용합니다.
```sh
npm ci
npm run build -w @hop/shared
npm run dev
```
웹과 API 모두 OS가 빈 임시 포트를 배정합니다. 프로젝트 루트 `.env`를 읽습니다. 개발 DB·파일은 기본 `.data/`를 사용하며 Docker의 `/data/` 설정을 그대로 사용하지 않습니다. 출력된 `개발 화면` 주소를 열어 주세요. 서버 코드 변경은 자동으로 다시 시작하며 실행 중에는 배정된 포트를 유지합니다. 고정 샘플 포트나 기존 서비스 포트를 사용하지 않습니다. 데이터는 `.data/hop.sqlite`에 저장합니다. 동일 브라우저의 탭은 하나의 기기이므로 다른 기기를 테스트하려면 다른 브라우저·프로필·시크릿 창을 사용하세요.
## 검증
```sh
npm run typecheck
npm run build
npm test
```
API 테스트는 격리된 실제 SQLite DB와 임시 로컬 포트의 Socket.IO를 사용합니다. 테스트 환경이 로컬 소켓 연결을 제한하면 권한이 필요할 수 있습니다. 운영 프록시와 실기기 확인을 자동 테스트로 대신했다고 주장하지 않습니다.
## Docker 배포 준비
유그린 NAS에서 Docker 앱 버튼으로 배포하는 순서는 [deploy.md](deploy.md)를 따라 진행하세요. 현재 배포 경로는 Cloudflare Tunnel → 기존 NPM(8080) → NAS의 hop.(50189)입니다.
`.env.example`을 `.env`로 복사하고 비밀값을 설정합니다. `SESSION_SECRET`과 `DISCOVERY_SECRET`은 서로 다른 32자 이상의 무작위 비밀값이어야 합니다. 예를 들어 `openssl rand -hex 32`로 각각 생성합니다. Git에 실제 환경 파일·DB·파일을 넣지 않습니다.
이번 NAS 배포는 사용자가 지정한 **50189**를 호스트·컨테이너 포트로 사용합니다. `scripts/configure-nas.sh`가 실제 NAS 내부 IP를 물어보고 운영 `.env`와 배포 YAML을 준비합니다. 다른 환경에서는 충돌 없는 포트·바인딩 IP·프록시 경로를 따로 확인하세요. Compose는 필수 값이 비어 있으면 실행을 거절합니다. `.env`는 Node 서버 직접 실행 시 자동 로드되지 않으며 Compose 또는 실행 환경이 주입합니다.
```sh
docker compose config
docker compose up -d --build
```
앱 시작 시 버전 관리된 Drizzle 마이그레이션을 적용하며 실패하면 시작하지 않습니다. DB와 업로드 볼륨은 재배포·재시작 후 유지됩니다. `docker compose down -v`는 데이터를 삭제하므로 사용하지 마세요.
Nginx Proxy Manager에는 실제 컨테이너/호스트 경로의 포트를 지정하고 HTTPS·WebSocket 지원을 켭니다. Secure 쿠키 때문에 운영 환경에서는 HTTPS가 필수입니다. 요청·연결 타임아웃과 업로드 제한은 Phase 2에서 문서화하고 실제 경로에서 검증합니다. 앱의 파일 상한은 중간 프록시·Cloudflare 상한을 보장하지 않습니다.
## 발견과 QR·코드 연결
같은 네트워크 탐색은 기본 비활성입니다. `TRUSTED_PROXY_CIDRS`는 실제로 앱에 연결하는 검증된 프록시 IP/CIDR만 허용합니다. 임의 전달 헤더를 신뢰하지 않습니다. 검증된 클라이언트 IP를 얻지 못하면 네트워크 탐색을 하지 않습니다. 공인 IP가 정확히 같은 경우만 후보로 표시하고 IPv6 /64 통합은 하지 않습니다. 같은 공인 IP는 동일 Wi-Fi나 실제 근접의 증명이 아닙니다.
QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10대, 선택적 참여 승인, 내보내기와 공간 닫기를 지원합니다. 공간 참여와 자료 전송 수락은 별개입니다. QR 초대 토큰은 URL fragment에 넣어 서버 요청·리퍼러로 전달하지 않으며 참여 후 주소에서 지웁니다.
## 익명 자료
비로그인 화면에는 전송 내역을 표시하지 않습니다. 수락 후 링크는 새 탭으로 열고 파일은 다운로드합니다. 익명 기기 세션은 기본 30일이며 저장한 자료는 최대 7일입니다. 쿠키 삭제·만료·초기화 시 이전 익명 접근권은 복구할 수 없습니다. 로그인 전 수신 자료를 계정으로 가져오지 않습니다. 기기 세션 정리가 필요하면 브라우저의 사이트 데이터를 삭제합니다. 기기 이름은 인증된 신원이 아니며 전송은 서버를 경유합니다. E2EE나 P2P 전송을 제공한다고 안내하지 않습니다.
## 업데이트와 백업
업데이트 전 앱을 정지하고 DB 볼륨(관련 WAL·SHM 포함)과 업로드 볼륨을 같은 시점에 백업합니다. SQLite 파일만 실행 중에 임의 복사하지 마세요. 복원은 별도 볼륨에서 DB integrity check와 실제 자료 접근을 확인한 뒤 적용합니다. 실제 Docker 백업·복원 검증은 Phase 5에서 수행합니다.
파일당 기본 2GiB, 익명 주체당 2GiB, 전체 익명 20GiB 제한과 최소 5GiB 여유 공간을 적용합니다. MB/KB 환경변수는 MiB/KiB 기준입니다. 파일은 수락 후 스트리밍 저장하고 중단 시 임시 파일·예약을 회수합니다. 초기에는 업로드 재개나 백그라운드 완료를 보장하지 않습니다.
## 개발 환경의 QR
기본 개발 URL은 `127.0.0.1`입니다. 이 URL을 휴대폰에서 열면 휴대폰 자신을 가리키므로 PC 개발 서버에 접속할 수 없습니다. 같은 PC의 다른 브라우저·시크릿 창에서는 코드를 입력해 연결할 수 있습니다. 휴대폰 검증을 시작할 때 사용할 LAN 접속 주소·포트·바인딩 범위를 사용자에게 확인합니다. HTTPS 운영 배포에서는 공개 서비스 주소로 QR을 생성합니다. 개발 HTTP 환경에서는 플랫폼에 따라 클립보드 등 보안 컨텍스트 API가 제한될 수 있습니다.
## 이메일 발송
2026-10-08 사용자 지침에 따라 명세의 SMTP 대신 **기존 Resend 연동**을 사용합니다. 발신 주소는 `hello@noname.company`입니다. Resend HTTPS API 발송 어댑터를 연결했습니다. 루트 `.env`의 `RESEND_API_KEY`와 `MAIL_FROM`을 서버에서 읽으며 API 키는 브라우저로 전달하지 않습니다. 공개 가입은 기본 닫혀 있으며, `REGISTRATION_MODE=open`으로 시작하려면 사용 가능한 메일 어댑터가 필요합니다. API 키는 Git에 저장하지 않습니다.
계정 메일 토큰은 일회용·해시 저장이며 링크 fragment를 사용합니다. 인증 전 계정 업로드·원격 전송·수락을 금지합니다. 이메일 정규화는 양끝 공백 제거와 도메인 소문자화이며 로컬 파트 대소문자·점·별칭은 보존합니다. ‘내 계정에 저장’은 기존 익명 접근권을 제거하고 계정 보관함으로 이동하며 원래 만료와 상대 접근권을 유지합니다.
최초 관리자 초기화는 `SETUP_TOKEN`(32자 이상 무작위 비밀값)과 빈 사용자 DB 조건으로만 가능합니다. `/api/auth/setup`도 이메일 인증을 거치며, 실제 메일 수신 검증 후 실행 절차를 완성합니다.
기술 참고: [Fastify 프록시 설정](https://fastify.dev/docs/latest/Reference/Server/), [Drizzle SQLite](https://orm.drizzle.team/docs/sqlite/get-started-sqlite), [Tailwind Vite](https://tailwindcss.com/docs/installation/using-vite).
## 현재 전송 UX
[파일·링크 통합 수정안](docs/TRANSFER_UX.md)을 우선합니다. 일반 텍스트 전송과 유형 탭은 제거했습니다. 기기를 누르면 클립보드 URL을 제안하며, 권한 거부나 일반 텍스트는 무시하고 파일 선택을 유지합니다. 링크 직접 붙여넣기도 가능합니다. 읽기만으로 전송하지 않으며 수락 후에만 전달합니다. 수신과 완료 카드는 뷰포트 상단에 표시됩니다. 기본 테마는 검정이며 라이트·시스템 전환을 유지합니다.
## 개발 테스트 계정
`node --import tsx scripts/seed-test-accounts.mjs`로 개발 DB에 인증된 일반 사용자 `test1@hop.test`, `test2@hop.test`를 준비합니다. 비밀번호는 둘 다 `1234`입니다. 메일을 보내지 않으며 운영 환경에서는 실행을 거절합니다. 같은 이름의 테스트 계정은 비밀번호만 재설정하고 다른 기존 계정은 변경하지 않습니다.
개발·테스트 환경은 1자 이상 비밀번호를 허용합니다. 운영 환경(`NODE_ENV=production`)은 기존 12자 이상 규칙을 유지하며 가입·재설정 화면도 서버가 제공한 최소 길이를 적용합니다.
## 현재 전송·보관함 흐름
최신 동작 기준은 [TEMPORARY_VAULT.md](docs/TEMPORARY_VAULT.md)입니다. 중앙에서 기기를 고른 뒤 PC 모달·모바일 하단 시트로 링크·파일·임시 저장 자료를 보냅니다. 로그인 중 받은 자료는 보관함의 받은 항목에 날짜별로 남고, 직접 담은 자료는 임시 저장에서 한 줄 메모와 함께 관리합니다. 모든 자료는 최대 7일입니다. 저장 종류는 먼저 링크/파일 중 하나를 선택합니다. 이메일 원격 전송과 영구 즐겨찾기는 제거했습니다.
개발 실행은 같은 PC의 다른 브라우저를 자동 발견하고 반복 검증용 요청 한도를 사용합니다. QR 방에 들어가면 방 안의 기기만 보이며 한 방만 참여할 수 있습니다. 방장 나가기는 방 전체를 닫습니다. 운영 발견은 실제 프록시 경로를 검증한 후 켭니다.
`npm run test:e2e:layout`은 실제 12개 기기 연결과 4/3/2열, 선택 전 입력 없음, 중앙 모달/하단 시트 및 방 격리를 검증합니다.

519
SPEC.md Normal file
View File

@@ -0,0 +1,519 @@
# hop. 프로젝트 개발 명세서
> 2026-10-08 사용자 수정안: 신규 전송은 파일·HTTP/HTTPS URL만 지원하고 일반 텍스트 전송과 유형 탭을 제거한다. 기기 선택 시 클립보드 URL 읽기를 시도하는 통합 전송 UX는 [TRANSFER_UX.md](docs/TRANSFER_UX.md)를 우선 적용한다. 이메일 발송은 SMTP 대신 Resend(`hello@noname.company`)를 사용한다. 기본 테마는 검정이다.
> 2026-10-12 추가 수정안: 명시적으로 수락한 전송은 링크 자동 새 탭 열기·파일 자동 다운로드로 완료하고 별도 도착 확인 팝업을 제거한다. 비로그인 전송 내역 UI를 제거하며 취소는 전송 버튼 자리에서 처리한다. 수락 요청은 중앙 고대비 팝업으로 표시한다. 프런트엔드는 Vue + JavaScript로 전환한다. 개발·테스트 환경은 1자 이상 비밀번호를 허용하고 운영은 12자 이상 규칙을 유지한다.
> 2026-10-12 최종 화면·보관 구조 수정: [TEMPORARY_VAULT.md](docs/TEMPORARY_VAULT.md)를 우선 적용한다. 메인은 중앙 기기 선택과 선택 후 전송 모달/하단 시트로 구성하며, 보관함 안에 임시 저장·날짜별 받은 항목을 둔다. 로그인 중 수신만 계정에 기록한다. 모든 저장은 최대 7일이고 새 이메일 원격 전송·영구 즐겨찾기·익명 사후 가져오기를 제거한다. 한 기기는 한 방에만 속하며 방 참여자는 주변 목록과 격리한다. 방장이 나가면 모두 나간다.
> 2026-10-12 최종 레이아웃·콘텐츠 선택 수정: QR은 기기 카드 영역에 함께 배치하고 내 기기는 푸터의 작은 상태/설정 버튼으로 옮긴다. 전송 모달은 링크·파일·로그인 보관함 중 현재 콘텐츠 하나와 전송 버튼 하나만 표시하며 선택 방법에 따라 크기를 변경하지 않는다. 보관함 항목 선택만으로 전송하지 않는다.
버전: 3.0
서비스명: hop.
슬로건: 기기 사이를 가볍게.
서비스 주소: https://hop.noname.company
배포 환경: UGREEN DXP6800PRO / Docker Compose
이 문서는 이전 명세를 대체하는 독립적인 개발 기준이다. 이전 버전의 계정 중심 홈이나 기기별 영구 수신함을 함께 구현하지 않는다.
## 1. 목적과 제품 방향
hop.은 가입 없이 접속한 기기 사이에서 파일, 텍스트, URL을 간단히 전달하는 웹서비스다. 홈 화면은 현재 연결 가능한 기기를 보여주고, 기기를 선택해 전송 요청을 보내며, 상대가 수락하면 전달한다.
멀리 있거나 사이트에 접속하지 않은 사람에게는 이메일 주소로 전달한다. 이 보조 기능은 이메일 인증 계정을 사용하고 서버에 저장해 나중에 확인할 수 있게 한다. 자기 이메일로 저장하면 다른 기기에서 꺼내 쓸 수 있다.
최우선 목표는 누구나 가볍게 사용할 수 있는 것이다. 핵심은 ‘접속 → 대상 선택 → 수락 → 전달’이다. 복잡한 폴더 관리나 클라우드 드라이브 기능을 중심에 두지 않는다. 기기 이름 변경을 지원하며 계정 가입을 홈 진입 조건으로 만들지 않는다.
## 2. 두 가지 전송 방식
| 항목 | 즉석 전송 | 이메일 전송 |
|---|---|---|
| 목적 | 현재 접속한 기기로 바로 전달 | 원격 또는 오프라인 회원에게 전달 |
| 회원가입 | 불필요 | 송신자와 수신자 모두 인증 계정 필요 |
| 대상 | 같은 네트워크의 접속 기기 또는 QR·코드로 연결한 기기 | 인증된 이메일 주소 |
| 수락 | 매 전송 수락 | 낯선 발신자는 요청 수락, 허용 발신자는 자동 수신 |
| 수신 범위 | 선택한 브라우저 기기 | 계정의 모든 로그인 기기에서 공유 |
| 오프라인 대상 | 전송 불가 | 서버에 저장 가능 |
| 보관 | 익명 브라우저에서 유효기간 동안 접근 | 계정 보관함에서 접근 |
초기 파일 전송은 NAS 서버를 경유한다. 즉석 전송은 즉시 전달하는 UX를 뜻하며 P2P나 서버 미저장을 뜻하지 않는다. WebRTC P2P는 초기 범위에서 제외한다.
## 3. 접속 기기와 이름
### 3.1 익명 기기 세션
접속 시 서버가 임의 기기 식별자와 익명 세션을 발급한다. 브라우저 지문은 수집하지 않는다. 공개 기기 식별자는 인증 토큰과 분리한다. 인증 비밀값은 HttpOnly 쿠키로 관리하고 클라이언트가 제출한 기기 ID만으로 권한을 인정하지 않는다.
브라우저 또는 프로필을 하나의 기기로 취급한다. 동일 브라우저의 여러 탭은 하나로 합치고 모든 연결이 종료된 뒤 유예 시간 후 오프라인 처리한다. 다른 브라우저나 프로필은 별도 기기다. 초기 익명 세션의 서버 유효기간은 30일이며 쿠키 삭제·만료 시 이전 익명 수신함을 복구할 수 없음을 안내한다.
### 3.2 기기 이름 변경
첫 방문에는 플랫폼 기반 기본 이름을 제안하고 언제든 수정할 수 있다. 예: Mac Studio, Galaxy S25 Ultra, iPad Pro, Nothing Phone. 이름은 1~40자이며 제어문자와 HTML 실행을 허용하지 않는다. 변경은 재접속 후 유지하고 연결된 상대의 목록에도 반영한다.
이름은 중복 가능하므로 서버가 생성한 짧은 구분 코드를 함께 표시한다. 사용자가 이름을 ‘관리자’나 특정인으로 지정해도 신원이 인증됐다고 표현하지 않는다. 이메일 계정과 기기 이름은 별개다. 로그인·로그아웃으로 기기 이름은 바뀌지 않는다. 익명 표시에는 이메일을 노출하지 않는다.
### 3.3 발견 범위
전체 사이트 접속자를 보여주지 않는다. 다음 범위의 기기만 목록에 표시한다.
1. 같은 네트워크: 서버가 검증한 클라이언트 공인 IP가 정확히 같은 온라인 기기 후보.
2. 연결 공간: QR 또는 짧은 코드로 같은 임시 공간에 참여한 온라인 기기.
같은 공인 IP는 물리적 근접이나 동일 Wi-Fi의 확실한 증거가 아니다. 회사 공유망이나 통신사 공유 IP에서 예상보다 넓은 범위가 표시될 수 있다. UI는 ‘주변’이라는 상위 개념 아래 해당 목록을 ‘같은 네트워크’로 명시하고 실제 거리나 Bluetooth 탐색이라고 표시하지 않는다. IPv6 주소의 임의 /64 통합은 하지 않는다. 같은 네트워크 탐색은 운영자가 끌 수 있으며 이때 QR·코드는 계속 제공한다.
기본 수신 대기는 켜져 있으며 기기 이름 옆에 ‘발견 허용’ 스위치를 제공한다. 끄면 자동 목록에서 숨고 새 즉석 요청도 받지 않는다. 기존 수락 전송은 계속 진행할 수 있다. 목록 노출 자체가 민감한 환경을 위해 설정 상태를 눈에 띄게 표시한다.
IP는 브라우저에 공개하지 않는다. 그룹 키는 서버 비밀값으로 HMAC 처리하고 접속 중에만 사용한다. 이름 조회·기기 목록 이벤트는 서버가 발견 범위를 검증한 세션에만 보낸다. 대상 ID를 알아도 다른 범위 기기로 직접 요청할 수 없다.
### 3.4 QR와 코드 연결
사용자가 임시 연결 공간을 만들면 QR와 사람이 입력할 짧은 코드를 제공한다. QR는 충분한 엔트로피의 초대 토큰을 사용하고 코드는 예측·대입 방지를 위해 발급·조회 횟수를 제한한다. 기본 공간 유효기간은 10분, 최대 참여 기기는 10대다.
다른 네트워크의 기기도 참여할 수 있다. 기본은 QR·코드를 가진 기기가 즉시 참여하며 생성자에게 참여 사실과 이름을 알린다. 생성자가 ‘참여 승인 필요’를 켠 공간에서는 이름을 확인한 뒤 승인한다. 코드 자체를 참여 허가로 취급하지만 파일 전송 수락은 생략하지 않는다. 공간에 들어온 이후에도 파일 전송은 별도 수락을 받는다. 생성자는 참여자를 내보내거나 공간을 닫을 수 있다. 만료·퇴장 이후 새 요청은 금지하지만 이미 수락한 전송과 수신 자료의 접근권은 자체 유효기간에 따라 유지한다. QR·코드는 다운로드 링크가 아니다.
## 4. 즉석 전송 흐름
1. 접속 후 자신의 기기 이름과 발견 허용 상태를 확인한다.
2. 같은 네트워크 또는 연결 공간에서 온라인 대상 기기를 선택한다.
3. 텍스트·URL을 입력하거나 파일을 선택한다.
4. 서버에 전송 요청의 메타데이터를 보낸다. 수락 전 파일 바이트를 업로드하지 않는다.
5. 수신 기기에 발신 기기 이름·구분 코드·유형·파일명·크기·개수와 수락·거절 버튼을 표시한다.
6. 수락 후 서버가 업로드 권한과 용량을 예약하고 송신자가 업로드한다. 텍스트·URL도 수락 후 전달한다.
7. 저장이 확정되면 수신 기기에 복사·링크 열기·다운로드 동작을 제공한다.
8. 완료 후 양쪽의 이번 기기 전송 목록에 결과를 표시한다.
요청 기본 유효기간은 60초다. 거절·만료·오프라인 대상에는 업로드를 시작하지 않는다. 수락 직전 대상 온라인·수신 상태를 다시 검사한다. 수락 후 잠시 연결이 끊겨도 완료된 자료는 유효한 익명 세션으로 다시 접속해 확인할 수 있다. 업로드는 초기 버전에서 중단 지점 재개를 보장하지 않으며 재시도 시 새 업로드로 처리한다.
자동 다운로드, 링크 자동 열기, 전체 화면 요청 팝업은 사용하지 않는다. 수락 전 텍스트 본문이나 이미지 미리보기를 자동 노출하지 않는다. 요청 내용을 악성 HTML로 실행하지 않는다. 익명 기기 이름은 신뢰할 수 있는 신원 증명이 아니므로 수신 카드에 필요한 구분 정보를 제공한다.
## 5. 이메일 계정과 인증
### 5.1 회원가입과 로그인
이메일이 로그인 ID이자 원격 전송 주소다. 별도 username이나 @아이디는 만들지 않는다. 가입 필드는 이메일, 비밀번호, 비밀번호 확인, 표시 이름이다. 비밀번호는 최소 12자, 긴 비밀번호와 붙여넣기를 허용하고 Argon2id로 해싱한다.
가입 후 이메일 인증을 필수로 한다. 인증 전 원격 전송·요청 수락·계정 파일 업로드를 금지한다. 인증 메일 재발급, 비밀번호 찾기·재설정, 비밀번호·이메일 변경, 로그아웃, 다른 세션 전체 로그아웃을 지원한다. 이메일 변경은 새 주소 인증 후 확정하고 이전 주소로 안내한다.
메일은 SMTP로 발송하고 인증·복구 토큰은 일회용, 유효기간 제한, 해시 저장을 적용한다. 가입·복구 응답은 계정 존재 여부를 불필요하게 노출하지 않는다. 이메일 정규화는 도메인 소문자화 등 명시된 보수적 규칙을 사용하며 제공자별 점·별칭 제거를 임의 적용하지 않는다.
세션은 서버 기반이며 쿠키는 HttpOnly, Secure, SameSite=Lax를 적용한다. 인증 성공 시 세션을 회전하여 고정 공격을 막는다. 비밀번호 재설정·정지·탈퇴 시 기존 세션과 Socket.IO 권한을 무효화한다. 익명 세션과 계정 세션은 분리한다.
### 5.2 익명 수신과 로그인
익명으로 받은 자료를 로그인 시 자동으로 계정에 합치지 않는다. 사용자에게 ‘내 계정에 저장’ 동작을 제공하고 명시적으로 선택한 자료만 해당 계정 접근권과 용량으로 옮긴다. 원래 만료 시각은 유지한다.
같은 브라우저에서 계정을 바꾸거나 로그아웃해도 익명 기기 수신 자료가 남을 수 있다는 점을 명확히 안내한다. 공유 기기에서는 ‘이 기기의 익명 자료와 세션 지우기’를 제공한다. 이 동작은 이전 익명 접근권을 폐기하고 새 익명 세션을 만들며 계정 자료는 삭제하지 않는다.
### 5.3 운영 모드
관리자는 공개 회원가입을 열거나 닫을 수 있다. SMTP 설정 없이 공개 가입을 켤 수 없다. 최초 관리자 생성은 안전한 초기화 절차로만 허용한다. 계정 역할은 user/admin, 상태는 인증 대기/활성/정지/탈퇴다. 관리자 화면은 사용자 본문과 파일을 기본 열람 기능으로 제공하지 않는다. 서버 운영자의 기술적 접근 가능성이 있으므로 E2EE를 제공한다고 안내하지 않는다.
## 6. 이메일 원격 전송
### 6.1 대상 지정
송신자는 인증된 계정으로 로그인한 뒤 수신자의 이메일 전체를 입력한다. 이메일 일부 자동완성, 전체 회원 목록, 이메일 검색 API는 제공하지 않는다. 이메일은 대상 지정에만 쓰며 다른 사용자에게 계정의 접속 기기나 온라인 상태를 공개하지 않는다.
대상은 인증된 기존 회원이어야 한다. 비회원 이메일에 첨부파일, 초대 메일, 가입 유도 메일을 자동 보내지 않는다. 유효하지 않은 대상과 수신 거부 대상의 오류는 ‘이 주소로 전송할 수 없습니다’처럼 처리하여 정지·차단 사유를 공개하지 않는다. 정확한 주소 전송의 성공·실패는 계정 존재를 일부 드러낼 수 있으므로 횟수 제한을 적용한다.
초기에는 한 번에 이메일 한 명에게 보내며 다중 이메일 전송은 후속 범위다. 자신의 인증 이메일로 보내면 수신 요청 없이 ‘내 보관함’에 저장한다. 모든 로그인 기기는 계정 보관함과 읽음 상태를 공유한다.
### 6.2 낯선 발신자와 허용 목록
사용자는 수신 정책을 다음 중 선택한다: 처음 보는 사람은 요청으로 받기(기본), 허용한 사람에게만 받기, 다른 사람에게 받지 않기. 차단은 허용보다 우선한다.
처음 보는 발신자는 메타데이터 기반 수신 요청만 생성한다. 수신자는 인증된 발신 이메일·표시 이름, 유형·파일명·크기·개수를 보고 수락·거절·차단한다. ‘이 발신자는 다음부터 바로 받기’를 선택하면 허용 목록에 추가한다. 이름과 이메일 주소만 인증됐으며 실제 인물을 보증한다고 표시하지 않는다.
낯선 발신자의 수락 전에는 파일을 업로드하지 않고 본문·이미지를 수신자에게 자동 노출하지 않는다. 요청은 기본 7일 후 만료한다. 승인 뒤 송신자가 오프라인이라면 ‘수락됨 · 전송 대기’로 표시하고 다음 접속에서 전송을 완료하도록 한다. 송신자 파일은 서버에 없으므로 자동 전달할 수 없으며 재선택이 필요할 수 있음을 명시한다. 입력 초안의 영구 로컬 저장은 기본 제공하지 않는다.
허용된 발신자는 수신자가 오프라인이어도 즉시 업로드·저장할 수 있다. 따라서 ‘오프라인 원격 전송’은 허용 관계 또는 이미 수락된 요청에서 완성된다. 첫 전송을 받기 위해 한 번 수락하는 절차가 필요하다는 점을 보내기 화면에서 안내한다.
승인 후 미완료 전송은 24시간 내 시작해야 한다. 승인·허용 상태는 업로드 시작과 전송 확정 시 다시 검사한다. 거절·차단·정지·만료되면 예약 용량과 임시 파일을 정리한다. 계정 보관 기간은 요청 시점이 아니라 데이터 저장 확정 시점부터 계산한다.
## 7. 데이터 유형과 전송 처리
### 7.1 텍스트
다국어 UTF-8, 여러 줄, 기본 최대 1MiB를 지원한다. 일반 텍스트로 렌더링하고 복사 성공·실패를 안내한다. 서버 제한을 환경변수로 조정할 수 있다.
### 7.2 URL
HTTP/HTTPS만 허용한다. 여러 줄에서 URL을 분리하고 유효하지 않은 입력을 표시한다. 복사, 개별 새 탭 열기, 선택 URL 일괄 열기를 제공한다. 팝업 차단 시 개별 링크를 제시한다. 여러 URL은 전송 묶음에 속한 개별 항목으로 저장한다.
미리보기는 전달 완료 이후 비동기적으로 수집하고 실패해도 전송은 유지한다. 서버 요청은 DNS IPv4/IPv6, 실제 연결 IP, 각 리디렉션을 검증하여 루프백·사설망·링크 로컬·메타데이터·내부 서비스 접근을 막는다. 타임아웃·응답 크기·리디렉션을 제한한다. 임의 쿠키·인증 정보를 보내지 않는다. 내부 URL 자체 전달은 허용하고 미리보기는 생략한다. 파비콘은 제한된 이미지로 서버에서 수집하여 수신자 브라우저가 자동으로 외부 사이트를 호출하지 않게 한다.
### 7.3 파일
파일 선택, 여러 파일, 드래그 앤 드롭, 파일명·크기, 개별 업로드 진행률, 취소, 다운로드를 지원한다. 파일당 기본 2GiB이며 multipart 스트리밍으로 저장한다. 전체 파일을 메모리에 적재하지 않는다. 서버는 실제 바이트 수와 선언 크기를 검증한다.
묶음 내 파일마다 성공·실패를 표시하고 성공한 파일은 유지하며 실패 파일만 재시도할 수 있다. 수락은 서버에 저장된 변경 불가능한 파일 목록과 크기에 결합한다. 파일 변경·추가·크기 증가 시 새 요청이 필요하다. 요청에 없는 파일 업로드는 거절한다.
지원 래스터 이미지에 인증된 미리보기를 제공한다. HTML·SVG·실행 콘텐츠는 인라인 실행을 금지한다. 다운로드는 실제 접근권을 검사하고 안전한 Content-Disposition·nosniff를 적용한다. 원본 이름은 경로로 사용하지 않고 서버 생성 키로 저장하며 저장 경로를 공개하지 않는다.
업로드 중단·브라우저 종료·저장 공간 부족 시 임시 파일과 용량 예약을 회수한다. 미확정 업로드는 기본 24시간 내 정리한다. 초기에는 청크 재개와 백그라운드 업로드를 보장하지 않는다. 파일을 안전하다고 보증하지 않는다.
### 7.4 클립보드와 중복 방지
사용자 동작으로 붙여넣기·복사를 제공하고 권한이 있으면 클립보드 읽기 버튼을 제공한다. 자동 백그라운드 감시는 없다. 전송 요청·수락·업로드 확정은 idempotency key로 중복 처리를 막는다. 같은 키에 다른 payload가 오면 충돌로 거절한다.
## 8. 보관함과 상태
| 화면 | 대상과 기능 |
|---|---|
| 이번 기기 | 익명 즉석 수신·전송 결과, 복사·열기·다운로드·삭제 |
| 받은 항목 | 계정의 전달 완료 자료, 읽음·검색·필터·즐겨찾기·삭제 |
| 수신 요청 | 아직 수락하지 않은 원격 요청, 수락·거절·차단 |
| 내 보관함 | 자신에게 저장한 자료, 다른 기기에서 사용·재전송 |
| 보낸 항목 | 원격 전송의 요청·전송·확인 상태, 내 목록 삭제 |
| 즐겨찾기 | 계정이 보존하도록 지정한 자료 |
영구 기기별 계정 수신함은 없다. ‘이번 기기’는 익명 세션 접근권이며 계정 받은 항목과 별개다. 일반 방문자가 다른 익명 기기의 과거 수신 내역을 볼 수 없다. 계정 목록은 검색, 유형·미확인 필터, 최신순, 페이지네이션과 다중 선택을 지원한다.
요청 상태는 대기/수락/거절/만료/취소, 전송 상태는 업로드 대기/업로드 중/저장 완료/실패, 읽음 상태는 미확인/확인 완료로 분리한다. 읽음은 상세 열기·명시적 복사·링크 열기·다운로드 시작에서 처리한다. 다운로드 시작은 다운로드 완료나 파일 실행을 의미하지 않는다. 목록 노출·썸네일·실시간 이벤트만으로 읽음 처리하지 않는다. 다시 미확인으로 바꿀 수 있다.
계정 읽음은 모든 로그인 기기에 공유되고 익명 읽음은 해당 익명 세션에만 적용한다. 확인한 자료는 자동 삭제하지 않는다. 송신자에게 확인 여부는 표시하되 수신자의 삭제·즐겨찾기 상태는 노출하지 않는다.
## 9. 밀어서 삭제와 실행 취소
삭제의 기본 의미는 ‘내 목록에서 삭제’다. 송신자가 보낸 항목을 삭제해도 수신 자료를 회수하지 않는다. 수신자가 삭제해도 송신자의 접근권은 남는다. 아직 수락되지 않은 자신이 보낸 요청은 별도 ‘요청 취소’ 동작을 제공한다.
- 모바일 행을 왼쪽으로 밀면 삭제 버튼을 드러내고 버튼을 눌러 삭제한다. 밀기만으로 즉시 삭제하지 않는다.
- 한 행만 펼치고 다른 행·바깥 터치로 닫는다. 세로 스크롤과 가로 의도를 구분하고 시스템 뒤로 가기 제스처와 충돌하지 않게 한다.
- 링크·복사·다운로드 버튼이나 텍스트 선택 조작에서 제스처를 시작하지 않는다.
- 행을 부드럽게 접고 ‘삭제됨 · 실행 취소’를 5초 제공한다. 다중 삭제도 작업별 서버 복원 ID를 사용한다.
- 서버의 5초 유예 안에서만 복원하고 자연 만료된 자료는 복원하지 않는다. 유예 중에는 물리 파일 정리를 보류한다.
- 서버 실패 시 행·선택·카운트를 복원하고 이해하기 쉬운 오류를 표시한다. 다른 로그인 기기의 상태도 동기화한다.
- 데스크톱 행 메뉴, 모바일 명시적 삭제 버튼, 키보드·스크린리더 접근을 함께 제공한다. 터치 목표 최소 44px와 동작 줄이기를 지원한다.
- 미확인/확인 필터와 선택 삭제를 제공해 확인한 항목을 쉽게 정리한다.
수신 요청을 목록에서 정리할 때는 거절로 처리하여 송신자와 상태가 일치하게 한다. 이미 진행 중인 전송을 단순 목록 삭제로 숨겨서 계속 업로드하게 하지 않는다. 진행 중 전송은 먼저 취소하고 양쪽에 상태를 알린다. 즐겨찾기 직접 삭제는 해당 보존 권한도 제거한다.
## 10. 보관 기간과 정리
| 유형 | 기본 기간 |
|---|---|
| 텍스트 | 30일 |
| URL | 30일 |
| 파일 | 7일 |
| 계정 즐겨찾기 | 자동 만료 없음 |
| 원격 수신 요청 | 7일 |
| 즉석 수신 요청 | 60초 |
일반 보관 기간은 데이터 저장 확정부터 계산하며 읽음으로 연장하지 않는다. 익명 자료에도 동일한 데이터 보관 기간을 적용하지만 익명 세션 만료·초기화 후 접근은 복구할 수 없다. 익명 즐겨찾기는 제공하지 않으며 로그인 후 내 계정에 저장해서 사용할 수 있다.
즐겨찾기는 사용자별 보존 권한이다. 한 사용자가 즐겨찾기해도 상대방의 기한은 늘어나지 않는다. 해제 시 원래 만료가 지났으면 즉시 만료 안내를 한다. 데이터는 유효한 접근권·즐겨찾기 또는 실행 취소 유예가 남아 있으면 유지하고 마지막 권한 종료 후 본문·파일을 정리한다. 여러 전송이 같은 파일을 참조하면 모든 참조를 확인한다.
운영자가 기본 기간을 바꾸면 새 자료에 적용한다. 파일·메타데이터·용량 회계를 함께 정리하며 고아 파일과 DB 불일치를 정기 확인한다. 최소 감사 기록은 본문과 원본 없이 기본 30일 보관한다.
## 11. 남용 방지와 용량
다음은 초기 정책 기본값이며 운영 설정으로 조정할 수 있다.
| 제한 | 초기 기본값 |
|---|---|
| 파일당 크기 | 2GiB |
| 전송당 파일 또는 URL | 20개 |
| 인증 계정 저장 용량 | 10GiB |
| 익명 세션 저장 용량 | 2GiB |
| 익명 세션당 동시 전송 | 1개 |
| 계정당 동시 업로드 | 2개 |
| 디스크 최소 여유 공간 | 5GiB |
| 익명 요청 발신 | 세션당 분당 5회, IP당 분당 20회 |
| 같은 익명 수신자에게 요청 | 송수신 쌍별 분당 2회 |
| 원격 신규 수신 요청 | 발신 계정당 하루 20회 |
| 미처리 원격 요청 | 수신 계정당 최대 50개 |
계정·익명 세션·IP·송수신 쌍을 함께 제한하고 익명 쿠키를 새로 만들어도 IP 제한을 우회하지 못하게 한다. 공유 IP의 정상 사용자 영향이 있으므로 운영에서 조정 가능하게 한다. 거절 후 같은 쌍은 기본 5분간 재요청을 제한한다. 수신자는 익명 상대를 세션 단위로 차단하고 발견 허용을 끌 수 있다. 익명 차단은 상대의 새 세션 생성까지 영구 식별하지 못한다는 한계를 명시한다.
익명 저장소에는 서비스 전체 20GiB 기본 상한을 추가하여 다수 세션 생성으로 NAS를 채우는 것을 제한한다. 수신 수락은 업로드 시작의 필수 조건이고 파일 크기 기반 용량 예약을 원자적으로 처리한다. 저장 공간 부족 시 수락·신규 업로드를 거절하고 기존 다운로드는 가능한 한 유지한다.
원본 파일은 송신 계정 또는 익명 세션의 사용량으로 계산하고 수신자가 즐겨찾기하면 수신자의 즐겨찾기 용량에도 논리 크기로 계산한다. 자기 보관함은 중복 합산하지 않는다. 실제 파일은 중복 저장하지 않아도 논리 용량은 계정별로 제한한다. 파일을 상대가 보존하는 동안 원본 소유자의 용량도 유지될 수 있음을 안내한다. 만료·삭제·재전송 참조와 용량 예약을 일관되게 계산한다.
이메일 인증만으로 남용을 막을 수 있다고 가정하지 않는다. 가입·로그인·메일 재발급·코드 대입에도 IP와 계정 기반 속도 제한을 둔다. 원격 발신자는 차단·신고할 수 있고 관리자에게 계정 정지와 신고 처리 화면을 제공한다. 신고는 식별자·사유 중심이며 본문·파일을 자동 복제하지 않는다. 초기에는 원격 요청마다 이메일 알림을 발송하지 않아 메일 폭탄을 방지한다. 인증·복구용 메일만 필수다.
## 12. 실시간 통신과 PWA
REST API와 DB가 상태의 기준이며 Socket.IO는 기기 발견·전송 요청·변경 알림을 전달한다. 서버가 인증한 익명 세션, 계정, 발견 범위 또는 공간으로 room을 구성하고 클라이언트가 임의 room에 가입할 수 없게 한다. 이벤트에는 필요한 최소 정보만 포함한다.
세션 만료·계정 정지·범위 변경·공간 퇴장 시 room과 권한을 다시 계산한다. 다중 탭 중 하나가 닫혔다고 기기 전체를 오프라인 처리하지 않는다. 재연결·탭 복귀 시 현재 요청과 수신 목록을 API로 다시 조회한다. 모바일 백그라운드나 브라우저 종료 시 온라인 상태를 보장하지 않는다.
PWA는 hop. 이름·아이콘·독립 실행·정적 리소스 캐시를 제공한다. 지원 플랫폼의 공유 메뉴에서는 파일·URL·텍스트를 보내기 초안으로 가져오고 사용자가 대상과 전송을 확정한다. 자동 전송은 하지 않는다. 미지원 브라우저는 붙여넣기·파일 선택으로 대체한다.
인증 API, 메시지, 파일, 수신자 정보는 공용 서비스 워커 캐시에 넣지 않는다. 로그아웃과 계정 전환 시 계정별 화면·초안·캐시를 정리한다. 익명 자료는 별도 수명 정책을 적용하고 공유 기기 초기화 동작을 제공한다. Web Push와 오프라인 새 전송은 초기 범위가 아니다.
## 13. UI 구성
미니멀하고 가벼운 전송 도구로 디자인한다. 라이트·다크·시스템 테마, 모바일 우선 반응형, 명확한 진행 상태와 오류 안내를 제공한다.
- 홈: hop., 내 기기 이름 변경, 발견 허용, 같은 네트워크 기기 카드, QR·코드 연결, 빠른 전송. 로그인 없이 접근한다.
- 즉석 보내기: 대상 기기, 파일·텍스트·URL, 수락 대기·전송 진행·결과.
- 즉석 받기: 조용한 요청 카드, 수락·거절·차단, 전송 후 복사·열기·다운로드.
- 이번 기기: 익명 전송·수신 목록과 삭제, 로그인 후 선택 자료 저장.
- 이메일로 보내기: 로그인 안내 또는 이메일 입력, 요청인지 즉시 전송인지 상태 안내.
- 계정 보관함: 받은 항목·수신 요청·내 보관함·보낸 항목·즐겨찾기.
- 계정 화면: 가입·인증·로그인·복구, 이메일·비밀번호, 허용·차단, 용량, 세션, 탈퇴.
- 설정: 현재 기기 이름, 발견 허용, 테마, 익명 기기 초기화.
- 관리자: 가입 모드·계정 정지·신고·용량 제한·정리 작업과 저장소 상태.
데스크톱은 기기 카드와 전송 패널을 중심으로 구성한다. 모바일 하단 내비게이션은 ‘기기’, ‘이번 기기’, ‘보관함’으로 하고 로그인은 보관함과 이메일 전송에서만 요구한다. 주변 전송보다 계정 가입·보관함을 더 크게 강조하지 않는다. 코드 연결은 네트워크가 달라도 쓸 수 있도록 눈에 띄게 제공한다.
## 13.1 가벼운 진입 흐름
첫 접속에서 회원가입 모달, 이름 입력 강제, 튜토리얼, 알림 권한 요청을 띄우지 않는다. 기본 기기 이름으로 곧바로 사용하고 이름 변경은 홈에서 한 번의 조작으로 접근한다. 위치·Bluetooth·연락처 권한을 요구하지 않는다. 로그인 버튼은 보조로 배치한다.
발견 가능한 기기가 없으면 ‘상대도 hop.을 열어주세요’와 QR·코드 연결을 보여준다. 회원가입을 해결책으로 먼저 제시하지 않는다. 파일·텍스트·URL은 하나의 전송 입력 영역에서 쉽게 선택하고 드래그 또는 붙여넣기로 시작할 수 있게 한다. 보안 설정의 복잡한 용어를 일반 사용자 흐름에 노출하지 않는다. 수락·거절과 상태 안내는 짧고 분명한 문구를 사용한다.
## 13.2 광고 수익화
메인 화면에 광고를 배치할 수 있도록 독립적인 광고 슬롯을 설계한다. 초기 구현은 광고 기능을 꺼둔 상태로 제공하며 실제 광고 계정·코드가 준비되면 운영자가 활성화한다. 특정 사업자의 수익이나 승인을 보장하지 않는다. 광고 사업자 연동 및 공개 시점의 정책·동의 요건 검토는 실제 활성화 단계에서 수행한다.
- 데스크톱은 기기·전송 영역 바깥의 보조 영역, 모바일은 핵심 동작 아래 별도 영역에 한 개의 기본 슬롯을 둔다.
- ‘광고’라고 명확히 표시하고 전송·다운로드·수락 버튼처럼 보이는 자체 UI를 만들지 않는다.
- 전면 광고, 강제 시청, 다운로드 전 광고 클릭, 팝업 광고, 수락 대화상자 안 광고는 제공하지 않는다.
- 전송 진행·수신 요청·파일 상세·로그인·계정 보관함 화면에는 광고를 표시하지 않는다. 광고 로딩 실패나 차단이 주요 기능을 막아서는 안 된다.
- 슬롯 공간을 예약하여 늦은 로딩이 전송 버튼 위치를 바꾸거나 오터치를 유발하지 않게 한다. 광고 스크립트는 핵심 앱 로딩을 차단하지 않는다.
- 파일명·본문·공유 URL·이메일·기기 이름·공간 코드·접속 IP 그룹을 광고 타기팅 데이터나 광고 요청 매개변수로 보내지 않는다.
- 광고 스크립트가 같은 페이지에서 실행되면 그 자체로 개인정보 경계가 된다고 가정하지 않는다. 사업자가 지원하는 격리 방식과 최소한의 비민감 홈 상태를 검토하고, 계정·요청·내용 화면으로 진입할 때 광고를 제거하거나 별도 비광고 문서로 전환한다.
- 제3자 광고가 활성화되면 ‘외부 서비스 의존 없음’ 또는 ‘접속 정보가 외부로 전혀 전달되지 않음’이라고 안내하지 않는다. 해당 사업자 수집과 필요한 동의·설정을 실제 구성에 맞춰 공개한다.
광고 없이도 핵심 기능을 완전히 사용할 수 있어야 한다. 유료 구독이나 파일 보관 업셀은 초기 범위에 추가하지 않는다.
## 14. 기술 아키텍처
| 영역 | 기술 |
|---|---|
| Frontend | Vue 3, TypeScript, Vite, Tailwind CSS |
| Backend | Node.js, TypeScript, Fastify |
| 실시간 | Socket.IO |
| DB | SQLite, Drizzle ORM, 마이그레이션 |
| 파일 | NAS 비공개 영속 볼륨 |
| 인증 | 익명 세션과 계정 서버 세션, Argon2id |
| 메일 | 설정 가능한 SMTP |
| 배포 | Docker Compose, Nginx Proxy Manager, HTTPS |
apps/web, apps/server, packages/shared로 단일 저장소를 구성한다. 단일 NAS에서 시작하고 DB·저장소·메일 모듈을 분리한다. Redis·PostgreSQL·외부 저장소·다중 서버는 초기 필수가 아니며 공개 이용량 확대 시 별도 검증한다.
## 15. 데이터 모델
내부 UUID와 공개 이름·이메일을 분리한다. 시간은 UTC 저장, UI는 현지 시간으로 표시한다. 외래 키, 유일 제약, 만료·목록 조회 인덱스를 적용한다.
| 테이블 | 주요 역할 |
|---|---|
| users | 이메일, 표시 이름, 비밀번호 해시, 역할·상태·인증 시간 |
| account_sessions | 계정 세션 해시, 만료·폐기·최종 접속 |
| guest_sessions | 익명 세션 해시, 만료·폐기 |
| browser_devices | 익명 세션에 속한 기기 이름, 발견 허용, 최종 접속 |
| connection_spaces | 생성 세션, QR 토큰 해시, 코드 해시, 만료 |
| space_members | 공간·기기, 승인·퇴장 상태 |
| account_tokens | 인증·복구·이메일 변경용 일회용 토큰 해시 |
| transfer_requests | local/email 모드, 발신 주체, 수신 주체, 메타데이터, 수락·만료·취소 |
| transfer_batches | 요청 참조, 저장 상태, idempotency key, request hash |
| items | 묶음, 유형, 본문·URL·파일 참조, 저장·만료 시각 |
| item_access | 항목, 계정 또는 익명 주체, sender/recipient/self 역할, 읽음·즐겨찾기·삭제 |
| files | 소유 주체, 저장 키, 파일명·타입·크기·체크섬·상태 |
| user_rules | 허용·차단 관계와 수신 정책 |
| quota_reservations | 업로드·주체·예약 바이트·만료 |
| deletion_operations | 삭제 주체·복원 기한·대상·복원 결과 |
| reports | 신고자·발신자·요청 또는 항목·사유·처리 |
| system_settings | 운영 정책과 제한 |
| audit_events | 최소 행위·식별자·결과·시간 |
주체 필드는 user_id와 guest_session_id 중 정확히 하나만 값이 있도록 CHECK 제약을 적용한다. 접근권은 원본 항목과 분리하고 역할별 중복을 막는다. 온라인 상태는 활성 Socket 연결에서 계산하며 DB의 과거 last_seen만으로 온라인을 판단하지 않는다.
발견 IP 그룹은 접속 중 임시 서버 상태로 유지하고 공개 room 이름에 원본 IP를 넣지 않는다. 요청 수락·용량 예약·전송 확정·접근권 생성은 트랜잭션과 상태 전이 검증을 사용한다. 재전송은 새로운 전송·만료를 만들며 접근 가능한 기존 파일을 참조할 수 있다. 물리 정리는 모든 참조와 유예를 검사한다.
탈퇴 시 로그인 개인정보·토큰·세션·본인 접근권을 정리한다. 다른 수신자의 유효한 전달 자료는 자체 만료까지 유지하고 발신자를 ‘탈퇴한 사용자’로 표시한다. 이 보존 정책은 탈퇴 전에 안내한다. 익명 세션 정리도 상대의 유효한 접근권을 삭제하지 않는다.
## 16. API와 이벤트
```text
POST /api/guest/session
GET /api/device/me
PATCH /api/device/me
POST /api/device/reset
GET /api/discovery/devices
POST /api/spaces
POST /api/spaces/join
POST /api/spaces/:id/members/:memberId/approve
DELETE /api/spaces/:id/members/:memberId
DELETE /api/spaces/:id
POST /api/auth/setup
POST /api/auth/register
POST /api/auth/verify-email
POST /api/auth/resend-verification
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/me
POST /api/auth/forgot-password
POST /api/auth/reset-password
POST /api/auth/change-password
POST /api/auth/change-email
GET /api/account/sessions
DELETE /api/account/sessions/:id
DELETE /api/account
POST /api/transfer-requests
GET /api/transfer-requests
POST /api/transfer-requests/:id/accept
POST /api/transfer-requests/:id/reject
POST /api/transfer-requests/:id/cancel
POST /api/uploads
DELETE /api/uploads/:id
POST /api/transfers/:id/complete
GET /api/device/items
GET /api/inbox
GET /api/vault
GET /api/sent
GET /api/favorites
GET /api/access/:id
PATCH /api/access/:id/read
PATCH /api/access/:id/favorite
POST /api/access/delete-batch
POST /api/deletion-operations/:id/undo
POST /api/access/:id/save-to-account
POST /api/access/:id/resend
GET /api/access/:id/download
GET /api/access/:id/preview
GET /api/account/preferences
PATCH /api/account/preferences
GET /api/account/rules
PUT /api/account/rules/:targetUserId
DELETE /api/account/rules/:targetUserId
GET /api/account/storage
POST /api/reports
GET /api/admin/users
PATCH /api/admin/users/:id
GET /api/admin/settings
PATCH /api/admin/settings
GET /api/admin/reports
PATCH /api/admin/reports/:id
GET /api/health
```
API는 익명 허용·계정 필요·관리자 필요를 명시적으로 분리한다. 익명 허용은 인증 불필요를 뜻하지 않으며 서버 익명 세션과 대상 권한을 검증한다. API 목록은 커서 페이지네이션, 오류는 code/message/requestId를 사용한다. read/favorite은 목표 값을 받아 재시도 가능한 상태 변경으로 처리한다.
Socket.IO 이벤트: device:online, device:offline, device:updated, space:updated, request:created, request:updated, transfer:updated, inbox:updated, access:updated, account:revoked. 파일 바이트는 Socket.IO 이벤트로 보내지 않는다. 클라이언트 업로드 진행률과 서버 저장 확정은 구분한다.
## 17. 보안 요구사항
HTTPS, 안전한 쿠키, CSRF 토큰·Origin 검증, Socket.IO 인증·Origin 검증을 적용한다. 모든 항목·파일·요청·공간·이벤트에서 계정 또는 익명 세션 접근권을 확인한다. 리소스 ID·기기 ID·공간 ID는 인증 비밀값이 아니다.
프록시의 실제 클라이언트 IP 해석은 명시적으로 신뢰한 Nginx Proxy Manager와 필요한 상위 프록시만 허용한다. 임의 X-Forwarded-For로 발견 범위와 rate limit을 우회하지 못하게 한다. Cloudflare를 경유한다면 신뢰 가능한 원본 IP 전달을 실제 환경에서 검증한다. 검증된 IP를 얻을 수 없으면 같은 네트워크 탐색을 끄고 QR·코드로 대체한다. 모든 사용자를 프록시 IP 하나로 묶어 노출하는 구현은 금지한다.
입력 검증, XSS 방어, CSP, 경로 조작 방어, 비공개 파일 저장, SSRF 방어, 업로드 제한을 구현한다. 로그에 세션·토큰·메시지·민감 URL을 남기지 않는다. QR 초대 토큰이 로그·리퍼러로 새지 않도록 처리하고 가입·로그인 페이지가 토큰을 외부로 전송하지 않게 한다.
서비스 공개 전에 가입 모드, SMTP, 요청 제한, 익명 저장소 상한, 백업·복구, 개인정보 안내와 신고·정지 기능을 설정한다. 사용자에게 E2EE나 물리적 근접 탐지를 제공한다고 표현하지 않는다.
## 18. Docker와 운영
```text
hop/
apps/web/
apps/server/
packages/shared/
docker/
compose.yaml
Dockerfile
.env.example
SPEC.md
README.md
```
```env
NODE_ENV=production
TZ=Asia/Seoul
PORT=3000
APP_URL=https://hop.noname.company
DATABASE_PATH=/data/database/hop.sqlite
UPLOAD_DIR=/data/uploads
SESSION_SECRET=replace-with-a-long-random-secret
DISCOVERY_SECRET=replace-with-another-random-secret
REGISTRATION_MODE=closed
EMAIL_VERIFICATION_REQUIRED=true
SAME_NETWORK_DISCOVERY_ENABLED=true
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
MAX_FILE_SIZE_MB=2048
MAX_TEXT_SIZE_KB=1024
USER_STORAGE_LIMIT_MB=10240
GUEST_STORAGE_LIMIT_MB=2048
TOTAL_GUEST_STORAGE_LIMIT_MB=20480
MIN_FREE_DISK_MB=5120
MAX_ITEMS_PER_TRANSFER=20
DEFAULT_FILE_RETENTION_DAYS=7
DEFAULT_TEXT_RETENTION_DAYS=30
DEFAULT_URL_RETENTION_DAYS=30
LOCAL_REQUEST_TIMEOUT_SECONDS=60
EMAIL_REQUEST_RETENTION_DAYS=7
ACCEPTED_UPLOAD_START_HOURS=24
SPACE_RETENTION_MINUTES=10
SPACE_MAX_DEVICES=10
GUEST_SESSION_RETENTION_DAYS=30
ORPHAN_UPLOAD_RETENTION_HOURS=24
DELETE_UNDO_SECONDS=5
AUDIT_RETENTION_DAYS=30
```
MB/KB 변수는 MiB/KiB 기준으로 계산하고 README에 표기한다. rate limit, 동시 업로드, 프록시 신뢰 범위, SMTP TLS와 정리 주기를 추가 설정 가능하게 한다. 비밀값과 실제 데이터는 Git에 포함하지 않는다.
Compose로 실행·마이그레이션하고 DB·파일은 영속 볼륨으로 분리한다. 자동 재시작·health check·로그를 제공한다. Nginx Proxy Manager의 HTTPS·WebSocket·업로드 크기와 시간 제한을 문서화한다. 앱의 2GiB 한도는 Cloudflare와 중간 프록시 한도를 보장하지 않으므로 실제 배포 경로에서 검증하고 필요한 직접 업로드 경로 또는 상한 조정을 문서화한다.
README에는 설치, 관리자 초기화, 가입 열기·닫기, SMTP, 발견 범위와 프록시 IP 설정, QR 연결, 업데이트, 백업·복구, 공간 부족·정리 실패 대응, 익명 자료 접근 제한을 포함한다. DB·파일의 일관된 백업과 복원 검증을 제공한다.
## 19. 구현 단계
### Phase 1 가입 없는 즉석 전송
프로젝트·Docker, 익명 세션, 이름 변경, 발견 허용, 검증된 IP 그룹, QR·코드 공간과 선택적 참여 승인, 온라인 상태, 요청 수락·거절, 텍스트·URL 전송, 이번 기기 목록을 구현한다. 범위 권한 검사와 요청 제한은 이 단계부터 적용한다.
### Phase 2 파일과 삭제
수락 이후 스트리밍 업로드, 진행률·취소, 인증 다운로드·미리보기, 익명 용량·전체 상한, 만료·고아 정리, 밀어서 삭제·실행 취소, 실제 모바일 동작을 구현한다.
### Phase 3 이메일 원격 전송
이메일 가입·인증·로그인·복구, 수신 요청·허용·차단, 오프라인 저장, 계정 보관함·내 보관함, 읽음·즐겨찾기·검색, 계정 저장 용량과 익명 자료 선택 저장을 구현한다.
### Phase 4 편의와 운영
PWA·공유 메뉴 초안, URL 미리보기, 테마, 비활성 기본 광고 슬롯, 재전송, 계정 세션 관리·탈퇴, 신고·관리자·가입 모드와 백업 문서를 완성한다.
### Phase 5 검증
익명·계정 간 권한 격리, 발견 범위·프록시 IP, 코드 대입 제한, 동시 용량 예약, 요청 상태 전이, 파일 중단, 삭제·복원·만료·즐겨찾기 정리, 재연결, 모바일·Docker 데이터 보존과 백업 복원을 검증한다.
## 20. 완료 기준
1. 로그인 없이 사이트를 열어 기기 이름을 변경하고 재접속 후 유지할 수 있다.
2. 같은 네트워크 목록에 전체 사이트 이용자가 노출되지 않고 발견 끄기가 즉시 반영된다.
3. 다른 네트워크의 기기를 QR·코드로 연결하며 선택적 참여 승인·퇴장·만료가 적용된다.
4. 여러 탭은 하나의 기기로 보이고 마지막 연결 종료 시 오프라인 처리된다.
5. 수락 전에 파일 업로드를 시작할 수 없고 거절·타임아웃 시 전달하지 않는다.
6. 익명 기기 간 파일·텍스트·URL을 실제로 전달하고 복사·다운로드할 수 있다.
7. 이메일 인증 계정으로 원격 요청을 보내고 첫 수락 이후 전달을 완료할 수 있다.
8. 허용된 발신자는 수신자가 오프라인이어도 저장할 수 있다.
9. 상대가 수락해도 송신자가 오프라인이면 전송 대기 상태를 정확히 안내한다.
10. 같은 계정의 여러 기기에서 동일한 계정 목록과 읽음·삭제 상태를 공유한다.
11. 밀어서 삭제가 스크롤과 충돌하지 않고 5초 실행 취소·서버 실패 복구가 작동한다.
12. 한쪽 목록 삭제가 상대의 자료를 회수하지 않는다.
13. 보관 기간·개별 즐겨찾기·마지막 접근권 정리가 일관되게 적용된다.
14. 다른 세션의 기기·파일·공간·계정 항목 ID를 알아도 무단 접근할 수 없다.
15. 동시·중복 요청이 업로드 제한·용량 제한·수신 정책을 우회하지 못한다.
16. 로그아웃·계정 전환과 익명 초기화가 정해진 데이터 경계를 유지한다.
17. Docker 재배포·NAS 재시작 후 자료가 유지되고 백업에서 복구할 수 있다.
18. 첫 접속에서 가입·이름 입력·권한 허용 없이 즉석 전송을 시작할 수 있다.
19. 광고 비활성·로딩 실패·차단 상태에서도 전송과 수신이 정상 동작하고 광고 때문에 핵심 버튼 위치가 바뀌지 않는다.
## 21. 초기 제외 범위와 개발 지침
Bluetooth 기반 물리적 근접 탐색, WebRTC P2P, 기기별 영구 계정 수신함, 비회원 이메일 전송·초대, 공개 다운로드 링크, 네이티브 앱, 자동 클립보드 동기화, E2EE, Web Push, 파일 버전 관리, 전송 회수, 다중 이메일 동시 전송, 요금제와 다중 서버는 초기 범위에서 제외한다.
이 문서를 기준으로 실제 API·DB·파일 저장을 연결한 앱을 개발한다. 먼저 아키텍처, 익명·계정 권한 모델, 요청·업로드 상태 전이, 정리·용량 규칙과 단계별 계획을 제시한다. Phase 1부터 구현하며 빌드와 관련 테스트를 단계별 실행한다. 더미 데이터로 기능 완료를 주장하지 않는다.
홈의 중심은 가입 없는 즉석 전송이며 계정 기능 때문에 진입 흐름을 바꾸지 않는다. 기기 이름 변경은 필수다. 브라우저의 기술적 제약이나 배포 프록시 제한을 숨기지 않는다. 실기기에서 확인하지 못한 모바일·PWA 동작은 검증 범위를 구분해 보고한다. 명세 밖의 중요한 제품 범위 변경은 구현 전에 질문한다. 코드·설치 문서·환경 예시·검증 결과·알려진 제한을 함께 제공한다.

View File

@@ -0,0 +1,2 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({ schema: './src/db/schema.ts', out: './drizzle', dialect: 'sqlite' });

View File

@@ -0,0 +1,126 @@
CREATE TABLE `item_access` (
`id` text PRIMARY KEY NOT NULL,
`itemId` text NOT NULL,
`guestId` text NOT NULL,
`role` text NOT NULL,
`read` integer DEFAULT false NOT NULL,
`deletedAt` integer,
FOREIGN KEY (`itemId`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `access_subject_role` ON `item_access` (`itemId`,`guestId`,`role`);--> statement-breakpoint
CREATE INDEX `access_guest` ON `item_access` (`guestId`,`deletedAt`);--> statement-breakpoint
CREATE TABLE `transfer_batches` (
`id` text PRIMARY KEY NOT NULL,
`requestId` text NOT NULL,
`status` text NOT NULL,
`storedAt` integer,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `transfer_batches_requestId_unique` ON `transfer_batches` (`requestId`);--> statement-breakpoint
CREATE TABLE `guest_blocks` (
`id` text PRIMARY KEY NOT NULL,
`guestId` text NOT NULL,
`targetGuestId` text NOT NULL,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`targetGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `guest_block_pair` ON `guest_blocks` (`guestId`,`targetGuestId`);--> statement-breakpoint
CREATE TABLE `browser_devices` (
`id` text PRIMARY KEY NOT NULL,
`guestId` text NOT NULL,
`name` text NOT NULL,
`code` text NOT NULL,
`discoverable` integer DEFAULT true NOT NULL,
`lastSeen` integer NOT NULL,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "device_name_length" CHECK(length("browser_devices"."name") between 1 and 40)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `browser_devices_guestId_unique` ON `browser_devices` (`guestId`);--> statement-breakpoint
CREATE UNIQUE INDEX `browser_devices_code_unique` ON `browser_devices` (`code`);--> statement-breakpoint
CREATE TABLE `guest_sessions` (
`id` text PRIMARY KEY NOT NULL,
`tokenHash` text NOT NULL,
`expiresAt` integer NOT NULL,
`revokedAt` integer,
`createdAt` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `guest_sessions_tokenHash_unique` ON `guest_sessions` (`tokenHash`);--> statement-breakpoint
CREATE INDEX `guest_expiry` ON `guest_sessions` (`expiresAt`);--> statement-breakpoint
CREATE TABLE `idempotency_records` (
`id` text PRIMARY KEY NOT NULL,
`guestId` text NOT NULL,
`operation` text NOT NULL,
`key` text NOT NULL,
`payloadHash` text NOT NULL,
`response` text NOT NULL,
`createdAt` integer NOT NULL,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idempotency_scope` ON `idempotency_records` (`guestId`,`operation`,`key`);--> statement-breakpoint
CREATE TABLE `items` (
`id` text PRIMARY KEY NOT NULL,
`batchId` text NOT NULL,
`kind` text NOT NULL,
`text` text,
`url` text,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`batchId`) REFERENCES `transfer_batches`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "item_payload" CHECK(("items"."kind" = 'text' AND "items"."text" IS NOT NULL AND "items"."url" IS NULL) OR ("items"."kind" = 'url' AND "items"."url" IS NOT NULL AND "items"."text" IS NULL))
);
--> statement-breakpoint
CREATE INDEX `items_expiry` ON `items` (`expiresAt`);--> statement-breakpoint
CREATE TABLE `space_members` (
`id` text PRIMARY KEY NOT NULL,
`spaceId` text NOT NULL,
`deviceId` text NOT NULL,
`status` text NOT NULL,
`joinedAt` integer NOT NULL,
FOREIGN KEY (`spaceId`) REFERENCES `connection_spaces`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`deviceId`) REFERENCES `browser_devices`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `space_device` ON `space_members` (`spaceId`,`deviceId`);--> statement-breakpoint
CREATE TABLE `transfer_requests` (
`id` text PRIMARY KEY NOT NULL,
`mode` text DEFAULT 'local' NOT NULL,
`senderGuestId` text NOT NULL,
`recipientGuestId` text NOT NULL,
`kind` text NOT NULL,
`byteSize` integer NOT NULL,
`count` integer NOT NULL,
`status` text NOT NULL,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
`acceptedAt` integer,
`startBy` integer,
FOREIGN KEY (`senderGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`recipientGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "request_different_guests" CHECK("transfer_requests"."senderGuestId" <> "transfer_requests"."recipientGuestId")
);
--> statement-breakpoint
CREATE INDEX `requests_sender_time` ON `transfer_requests` (`senderGuestId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_recipient_time` ON `transfer_requests` (`recipientGuestId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_expiry` ON `transfer_requests` (`status`,`expiresAt`);--> statement-breakpoint
CREATE TABLE `connection_spaces` (
`id` text PRIMARY KEY NOT NULL,
`creatorGuestId` text NOT NULL,
`tokenHash` text NOT NULL,
`codeHash` text NOT NULL,
`approvalRequired` integer NOT NULL,
`expiresAt` integer NOT NULL,
`closedAt` integer,
`createdAt` integer NOT NULL,
FOREIGN KEY (`creatorGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `connection_spaces_tokenHash_unique` ON `connection_spaces` (`tokenHash`);--> statement-breakpoint
CREATE UNIQUE INDEX `connection_spaces_codeHash_unique` ON `connection_spaces` (`codeHash`);--> statement-breakpoint
CREATE INDEX `space_expiry` ON `connection_spaces` (`expiresAt`);

View File

@@ -0,0 +1 @@
ALTER TABLE `transfer_requests` ADD `rejectedAt` integer;

View File

@@ -0,0 +1,71 @@
CREATE TABLE `deletion_targets` (
`id` text PRIMARY KEY NOT NULL,
`operationId` text NOT NULL,
`accessId` text NOT NULL,
FOREIGN KEY (`operationId`) REFERENCES `deletion_operations`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `deletion_operations` (
`id` text PRIMARY KEY NOT NULL,
`guestId` text NOT NULL,
`undoUntil` integer NOT NULL,
`createdAt` integer NOT NULL,
`restoredAt` integer,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `files` (
`id` text PRIMARY KEY NOT NULL,
`requestId` text NOT NULL,
`ownerGuestId` text NOT NULL,
`position` integer NOT NULL,
`storageKey` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` integer NOT NULL,
`checksum` text,
`status` text NOT NULL,
`uploadId` text,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`ownerGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "file_nonnegative_size" CHECK("files"."size" >= 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `files_storageKey_unique` ON `files` (`storageKey`);--> statement-breakpoint
CREATE UNIQUE INDEX `request_file_position` ON `files` (`requestId`,`position`);--> statement-breakpoint
CREATE INDEX `files_owner_state` ON `files` (`ownerGuestId`,`status`);--> statement-breakpoint
CREATE TABLE `quota_reservations` (
`id` text PRIMARY KEY NOT NULL,
`fileId` text NOT NULL,
`guestId` text NOT NULL,
`bytes` integer NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "reservation_nonnegative" CHECK("quota_reservations"."bytes" >= 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `quota_reservations_fileId_unique` ON `quota_reservations` (`fileId`);--> statement-breakpoint
CREATE INDEX `quota_guest_expiry` ON `quota_reservations` (`guestId`,`expiresAt`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_items` (
`id` text PRIMARY KEY NOT NULL,
`batchId` text NOT NULL,
`kind` text NOT NULL,
`text` text,
`url` text,
`fileId` text,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`batchId`) REFERENCES `transfer_batches`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "item_payload" CHECK(("__new_items"."kind" = 'text' AND "__new_items"."text" IS NOT NULL AND "__new_items"."url" IS NULL AND "__new_items"."fileId" IS NULL) OR ("__new_items"."kind" = 'url' AND "__new_items"."url" IS NOT NULL AND "__new_items"."text" IS NULL AND "__new_items"."fileId" IS NULL) OR ("__new_items"."kind" = 'file' AND "__new_items"."fileId" IS NOT NULL AND "__new_items"."text" IS NULL AND "__new_items"."url" IS NULL))
);
--> statement-breakpoint
INSERT INTO `__new_items`("id", "batchId", "kind", "text", "url", "fileId", "createdAt", "expiresAt") SELECT "id", "batchId", "kind", "text", "url", NULL, "createdAt", "expiresAt" FROM `items`;--> statement-breakpoint
DROP TABLE `items`;--> statement-breakpoint
ALTER TABLE `__new_items` RENAME TO `items`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `items_expiry` ON `items` (`expiresAt`);--> statement-breakpoint
ALTER TABLE `transfer_requests` ADD `manifest` text;

View File

@@ -0,0 +1 @@
ALTER TABLE `files` ADD `uploadKey` text;

View File

@@ -0,0 +1,191 @@
CREATE TABLE `account_sessions` (
`id` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`tokenHash` text NOT NULL,
`createdAt` integer NOT NULL,
`lastSeen` integer NOT NULL,
`expiresAt` integer NOT NULL,
`revokedAt` integer,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `account_sessions_tokenHash_unique` ON `account_sessions` (`tokenHash`);--> statement-breakpoint
CREATE INDEX `account_session_user` ON `account_sessions` (`userId`,`expiresAt`);--> statement-breakpoint
CREATE TABLE `account_tokens` (
`id` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`purpose` text NOT NULL,
`tokenHash` text NOT NULL,
`value` text,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
`usedAt` integer,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `account_tokens_tokenHash_unique` ON `account_tokens` (`tokenHash`);--> statement-breakpoint
CREATE INDEX `account_token_expiry` ON `account_tokens` (`expiresAt`);--> statement-breakpoint
CREATE TABLE `user_rules` (
`id` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`targetUserId` text NOT NULL,
`action` text NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`targetUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "rule_not_self" CHECK("user_rules"."userId" <> "user_rules"."targetUserId")
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_rule_pair` ON `user_rules` (`userId`,`targetUserId`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text,
`displayName` text NOT NULL,
`passwordHash` text,
`role` text DEFAULT 'user' NOT NULL,
`status` text NOT NULL,
`verifiedAt` integer,
`createdAt` integer NOT NULL,
`receivePolicy` text DEFAULT 'requests' NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_transfer_requests` (
`id` text PRIMARY KEY NOT NULL,
`mode` text DEFAULT 'local' NOT NULL,
`senderGuestId` text,
`recipientGuestId` text,
`senderUserId` text,
`recipientUserId` text,
`kind` text NOT NULL,
`byteSize` integer NOT NULL,
`manifest` text,
`count` integer NOT NULL,
`status` text NOT NULL,
`createdAt` integer NOT NULL,
`expiresAt` integer NOT NULL,
`acceptedAt` integer,
`rejectedAt` integer,
`startBy` integer,
FOREIGN KEY (`senderGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`recipientGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`senderUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`recipientUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "request_different_guests" CHECK("__new_transfer_requests"."mode" <> 'local' OR "__new_transfer_requests"."senderGuestId" <> "__new_transfer_requests"."recipientGuestId"),
CONSTRAINT "request_sender_subject" CHECK(("__new_transfer_requests"."senderGuestId" IS NOT NULL) + ("__new_transfer_requests"."senderUserId" IS NOT NULL) = 1),
CONSTRAINT "request_recipient_subject" CHECK(("__new_transfer_requests"."recipientGuestId" IS NOT NULL) + ("__new_transfer_requests"."recipientUserId" IS NOT NULL) = 1),
CONSTRAINT "request_mode_subjects" CHECK(("__new_transfer_requests"."mode" = 'local' AND "__new_transfer_requests"."senderGuestId" IS NOT NULL AND "__new_transfer_requests"."recipientGuestId" IS NOT NULL) OR ("__new_transfer_requests"."mode" = 'email' AND "__new_transfer_requests"."senderUserId" IS NOT NULL AND "__new_transfer_requests"."recipientUserId" IS NOT NULL))
);
--> statement-breakpoint
INSERT INTO `__new_transfer_requests`("id", "mode", "senderGuestId", "recipientGuestId", "senderUserId", "recipientUserId", "kind", "byteSize", "manifest", "count", "status", "createdAt", "expiresAt", "acceptedAt", "rejectedAt", "startBy") SELECT "id", "mode", "senderGuestId", "recipientGuestId", NULL, NULL, "kind", "byteSize", "manifest", "count", "status", "createdAt", "expiresAt", "acceptedAt", "rejectedAt", "startBy" FROM `transfer_requests`;--> statement-breakpoint
DROP TABLE `transfer_requests`;--> statement-breakpoint
ALTER TABLE `__new_transfer_requests` RENAME TO `transfer_requests`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `requests_sender_time` ON `transfer_requests` (`senderGuestId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_recipient_time` ON `transfer_requests` (`recipientGuestId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_expiry` ON `transfer_requests` (`status`,`expiresAt`);--> statement-breakpoint
CREATE INDEX `requests_sender_user` ON `transfer_requests` (`senderUserId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `requests_recipient_user` ON `transfer_requests` (`recipientUserId`,`createdAt`);--> statement-breakpoint
CREATE TABLE `__new_item_access` (
`id` text PRIMARY KEY NOT NULL,
`itemId` text NOT NULL,
`userId` text,
`guestId` text,
`role` text NOT NULL,
`read` integer DEFAULT false NOT NULL,
`deletedAt` integer,
`favorite` integer DEFAULT false NOT NULL,
FOREIGN KEY (`itemId`) REFERENCES `items`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "access_subject" CHECK(("__new_item_access"."guestId" IS NOT NULL) + ("__new_item_access"."userId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_item_access`("id", "itemId", "userId", "guestId", "role", "read", "deletedAt", "favorite") SELECT "id", "itemId", NULL, "guestId", "role", "read", "deletedAt", 0 FROM `item_access`;--> statement-breakpoint
DROP TABLE `item_access`;--> statement-breakpoint
ALTER TABLE `__new_item_access` RENAME TO `item_access`;--> statement-breakpoint
CREATE UNIQUE INDEX `access_subject_role` ON `item_access` (`itemId`,`guestId`,`role`);--> statement-breakpoint
CREATE UNIQUE INDEX `access_account_role` ON `item_access` (`itemId`,`userId`,`role`);--> statement-breakpoint
CREATE INDEX `access_account` ON `item_access` (`userId`,`deletedAt`);--> statement-breakpoint
CREATE INDEX `access_guest` ON `item_access` (`guestId`,`deletedAt`);--> statement-breakpoint
CREATE TABLE `__new_deletion_operations` (
`id` text PRIMARY KEY NOT NULL,
`userId` text,
`guestId` text,
`undoUntil` integer NOT NULL,
`createdAt` integer NOT NULL,
`restoredAt` integer,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "deletion_subject" CHECK(("__new_deletion_operations"."guestId" IS NOT NULL) + ("__new_deletion_operations"."userId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_deletion_operations`("id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt") SELECT "id", NULL, "guestId", "undoUntil", "createdAt", "restoredAt" FROM `deletion_operations`;--> statement-breakpoint
DROP TABLE `deletion_operations`;--> statement-breakpoint
ALTER TABLE `__new_deletion_operations` RENAME TO `deletion_operations`;--> statement-breakpoint
CREATE TABLE `__new_files` (
`id` text PRIMARY KEY NOT NULL,
`requestId` text NOT NULL,
`ownerGuestId` text,
`ownerUserId` text,
`position` integer NOT NULL,
`storageKey` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` integer NOT NULL,
`checksum` text,
`status` text NOT NULL,
`uploadId` text,
`uploadKey` text,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`ownerGuestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`ownerUserId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "file_nonnegative_size" CHECK("__new_files"."size" >= 0),
CONSTRAINT "file_owner_subject" CHECK(("__new_files"."ownerGuestId" IS NOT NULL) + ("__new_files"."ownerUserId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_files`("id", "requestId", "ownerGuestId", "ownerUserId", "position", "storageKey", "name", "type", "size", "checksum", "status", "uploadId", "uploadKey", "updatedAt") SELECT "id", "requestId", "ownerGuestId", NULL, "position", "storageKey", "name", "type", "size", "checksum", "status", "uploadId", "uploadKey", "updatedAt" FROM `files`;--> statement-breakpoint
DROP TABLE `files`;--> statement-breakpoint
ALTER TABLE `__new_files` RENAME TO `files`;--> statement-breakpoint
CREATE UNIQUE INDEX `files_storageKey_unique` ON `files` (`storageKey`);--> statement-breakpoint
CREATE UNIQUE INDEX `request_file_position` ON `files` (`requestId`,`position`);--> statement-breakpoint
CREATE INDEX `files_owner_state` ON `files` (`ownerGuestId`,`status`);--> statement-breakpoint
CREATE TABLE `__new_idempotency_records` (
`id` text PRIMARY KEY NOT NULL,
`userId` text,
`guestId` text,
`operation` text NOT NULL,
`key` text NOT NULL,
`payloadHash` text NOT NULL,
`response` text NOT NULL,
`createdAt` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "idempotency_subject" CHECK(("__new_idempotency_records"."guestId" IS NOT NULL) + ("__new_idempotency_records"."userId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_idempotency_records`("id", "userId", "guestId", "operation", "key", "payloadHash", "response", "createdAt") SELECT "id", NULL, "guestId", "operation", "key", "payloadHash", "response", "createdAt" FROM `idempotency_records`;--> statement-breakpoint
DROP TABLE `idempotency_records`;--> statement-breakpoint
ALTER TABLE `__new_idempotency_records` RENAME TO `idempotency_records`;--> statement-breakpoint
CREATE UNIQUE INDEX `idempotency_scope` ON `idempotency_records` (`guestId`,`operation`,`key`);--> statement-breakpoint
CREATE UNIQUE INDEX `account_idempotency_scope` ON `idempotency_records` (`userId`,`operation`,`key`);--> statement-breakpoint
CREATE TABLE `__new_quota_reservations` (
`id` text PRIMARY KEY NOT NULL,
`fileId` text NOT NULL,
`userId` text,
`guestId` text,
`bytes` integer NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "reservations_subject" CHECK(("__new_quota_reservations"."guestId" IS NOT NULL) + ("__new_quota_reservations"."userId" IS NOT NULL) = 1),
CONSTRAINT "reservation_nonnegative" CHECK("__new_quota_reservations"."bytes" >= 0)
);
--> statement-breakpoint
INSERT INTO `__new_quota_reservations`("id", "fileId", "userId", "guestId", "bytes", "expiresAt") SELECT "id", "fileId", NULL, "guestId", "bytes", "expiresAt" FROM `quota_reservations`;--> statement-breakpoint
DROP TABLE `quota_reservations`;--> statement-breakpoint
ALTER TABLE `__new_quota_reservations` RENAME TO `quota_reservations`;--> statement-breakpoint
CREATE UNIQUE INDEX `quota_reservations_fileId_unique` ON `quota_reservations` (`fileId`);--> statement-breakpoint
CREATE INDEX `quota_guest_expiry` ON `quota_reservations` (`guestId`,`expiresAt`);

View File

@@ -0,0 +1,17 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_deletion_operations` (
`id` text PRIMARY KEY NOT NULL,
`userId` text,
`guestId` text,
`undoUntil` integer NOT NULL,
`createdAt` integer NOT NULL,
`restoredAt` integer,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`guestId`) REFERENCES `guest_sessions`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "deletion_subject" CHECK(("__new_deletion_operations"."guestId" IS NOT NULL) + ("__new_deletion_operations"."userId" IS NOT NULL) = 1)
);
--> statement-breakpoint
INSERT INTO `__new_deletion_operations`("id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt") SELECT "id", "userId", "guestId", "undoUntil", "createdAt", "restoredAt" FROM `deletion_operations`;--> statement-breakpoint
DROP TABLE `deletion_operations`;--> statement-breakpoint
ALTER TABLE `__new_deletion_operations` RENAME TO `deletion_operations`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,3 @@
UPDATE transfer_requests SET status = 'cancelled' WHERE kind = 'text' AND status IN ('pending','accepted') AND EXISTS (SELECT 1 FROM transfer_batches b WHERE b.requestId = transfer_requests.id AND b.status IN ('waiting','uploading'));
--> statement-breakpoint
UPDATE transfer_batches SET status = 'failed' WHERE status IN ('waiting','uploading') AND EXISTS (SELECT 1 FROM transfer_requests r WHERE r.id = transfer_batches.requestId AND r.kind = 'text');

View File

@@ -0,0 +1,21 @@
CREATE TABLE `request_receipt_accounts` (
`requestId` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`sessionId` text NOT NULL,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`sessionId`) REFERENCES `account_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `vault_transfers` (
`requestId` text PRIMARY KEY NOT NULL,
`accessId` text NOT NULL,
`userId` text NOT NULL,
`sessionId` text NOT NULL,
FOREIGN KEY (`requestId`) REFERENCES `transfer_requests`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`accessId`) REFERENCES `item_access`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`sessionId`) REFERENCES `account_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
ALTER TABLE `item_access` ADD `note` text;

View File

@@ -0,0 +1,10 @@
-- Custom SQL migration file, put your code below! --
UPDATE items SET expiresAt = min(expiresAt, CAST(strftime('%s', 'now') AS INTEGER) * 1000 + 604800000);
--> statement-breakpoint
UPDATE item_access SET favorite = 0;
--> statement-breakpoint
UPDATE transfer_requests SET status = 'cancelled' WHERE mode = 'email' AND senderUserId <> recipientUserId AND status IN ('pending', 'accepted') AND id IN (SELECT requestId FROM transfer_batches WHERE status <> 'stored');
--> statement-breakpoint
UPDATE transfer_batches SET status = 'failed' WHERE requestId IN (SELECT id FROM transfer_requests WHERE mode = 'email' AND senderUserId <> recipientUserId AND status = 'cancelled');
--> statement-breakpoint
DELETE FROM quota_reservations WHERE fileId IN (SELECT files.id FROM files INNER JOIN transfer_requests ON files.requestId = transfer_requests.id WHERE transfer_requests.mode = 'email' AND transfer_requests.senderUserId <> transfer_requests.recipientUserId AND transfer_requests.status = 'cancelled');

View File

@@ -0,0 +1 @@
ALTER TABLE `transfer_requests` ADD `note` text;

View File

@@ -0,0 +1,7 @@
UPDATE space_members SET status = 'removed' WHERE spaceId IN (SELECT id FROM connection_spaces WHERE closedAt IS NOT NULL OR expiresAt <= CAST(strftime('%s','now') AS INTEGER)*1000);
--> statement-breakpoint
UPDATE space_members AS member SET status = 'removed' WHERE status IN ('pending','approved') AND EXISTS (SELECT 1 FROM space_members newer WHERE newer.deviceId = member.deviceId AND newer.status IN ('pending','approved') AND (newer.joinedAt > member.joinedAt OR (newer.joinedAt = member.joinedAt AND newer.id > member.id)));
--> statement-breakpoint
UPDATE connection_spaces SET closedAt = CAST(strftime('%s','now') AS INTEGER)*1000 WHERE closedAt IS NULL AND NOT EXISTS (SELECT 1 FROM space_members m INNER JOIN browser_devices d ON d.id = m.deviceId WHERE m.spaceId = connection_spaces.id AND m.status = 'approved' AND d.guestId = connection_spaces.creatorGuestId);
--> statement-breakpoint
UPDATE space_members SET status = 'removed' WHERE spaceId IN (SELECT id FROM connection_spaces WHERE closedAt IS NOT NULL);

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX `member_single_active_device` ON `space_members` (`deviceId`) WHERE "space_members"."status" IN ('pending', 'approved');

View File

@@ -0,0 +1,894 @@
{
"version": "6",
"dialect": "sqlite",
"id": "11892dda-406f-413c-ba07-9b439d0549db",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"item_access": {
"name": "item_access",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"itemId": {
"name": "itemId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"deletedAt": {
"name": "deletedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"access_subject_role": {
"name": "access_subject_role",
"columns": [
"itemId",
"guestId",
"role"
],
"isUnique": true
},
"access_guest": {
"name": "access_guest",
"columns": [
"guestId",
"deletedAt"
],
"isUnique": false
}
},
"foreignKeys": {
"item_access_itemId_items_id_fk": {
"name": "item_access_itemId_items_id_fk",
"tableFrom": "item_access",
"tableTo": "items",
"columnsFrom": [
"itemId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"item_access_guestId_guest_sessions_id_fk": {
"name": "item_access_guestId_guest_sessions_id_fk",
"tableFrom": "item_access",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transfer_batches": {
"name": "transfer_batches",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"requestId": {
"name": "requestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"storedAt": {
"name": "storedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"transfer_batches_requestId_unique": {
"name": "transfer_batches_requestId_unique",
"columns": [
"requestId"
],
"isUnique": true
}
},
"foreignKeys": {
"transfer_batches_requestId_transfer_requests_id_fk": {
"name": "transfer_batches_requestId_transfer_requests_id_fk",
"tableFrom": "transfer_batches",
"tableTo": "transfer_requests",
"columnsFrom": [
"requestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"guest_blocks": {
"name": "guest_blocks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"targetGuestId": {
"name": "targetGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"guest_block_pair": {
"name": "guest_block_pair",
"columns": [
"guestId",
"targetGuestId"
],
"isUnique": true
}
},
"foreignKeys": {
"guest_blocks_guestId_guest_sessions_id_fk": {
"name": "guest_blocks_guestId_guest_sessions_id_fk",
"tableFrom": "guest_blocks",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"guest_blocks_targetGuestId_guest_sessions_id_fk": {
"name": "guest_blocks_targetGuestId_guest_sessions_id_fk",
"tableFrom": "guest_blocks",
"tableTo": "guest_sessions",
"columnsFrom": [
"targetGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"browser_devices": {
"name": "browser_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"discoverable": {
"name": "discoverable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"lastSeen": {
"name": "lastSeen",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"browser_devices_guestId_unique": {
"name": "browser_devices_guestId_unique",
"columns": [
"guestId"
],
"isUnique": true
},
"browser_devices_code_unique": {
"name": "browser_devices_code_unique",
"columns": [
"code"
],
"isUnique": true
}
},
"foreignKeys": {
"browser_devices_guestId_guest_sessions_id_fk": {
"name": "browser_devices_guestId_guest_sessions_id_fk",
"tableFrom": "browser_devices",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"device_name_length": {
"name": "device_name_length",
"value": "length(\"browser_devices\".\"name\") between 1 and 40"
}
}
},
"guest_sessions": {
"name": "guest_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tokenHash": {
"name": "tokenHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revokedAt": {
"name": "revokedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"guest_sessions_tokenHash_unique": {
"name": "guest_sessions_tokenHash_unique",
"columns": [
"tokenHash"
],
"isUnique": true
},
"guest_expiry": {
"name": "guest_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"idempotency_records": {
"name": "idempotency_records",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payloadHash": {
"name": "payloadHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"response": {
"name": "response",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idempotency_scope": {
"name": "idempotency_scope",
"columns": [
"guestId",
"operation",
"key"
],
"isUnique": true
}
},
"foreignKeys": {
"idempotency_records_guestId_guest_sessions_id_fk": {
"name": "idempotency_records_guestId_guest_sessions_id_fk",
"tableFrom": "idempotency_records",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"items": {
"name": "items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"batchId": {
"name": "batchId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"items_expiry": {
"name": "items_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"items_batchId_transfer_batches_id_fk": {
"name": "items_batchId_transfer_batches_id_fk",
"tableFrom": "items",
"tableTo": "transfer_batches",
"columnsFrom": [
"batchId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_payload": {
"name": "item_payload",
"value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL)"
}
}
},
"space_members": {
"name": "space_members",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"spaceId": {
"name": "spaceId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deviceId": {
"name": "deviceId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"joinedAt": {
"name": "joinedAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"space_device": {
"name": "space_device",
"columns": [
"spaceId",
"deviceId"
],
"isUnique": true
}
},
"foreignKeys": {
"space_members_spaceId_connection_spaces_id_fk": {
"name": "space_members_spaceId_connection_spaces_id_fk",
"tableFrom": "space_members",
"tableTo": "connection_spaces",
"columnsFrom": [
"spaceId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"space_members_deviceId_browser_devices_id_fk": {
"name": "space_members_deviceId_browser_devices_id_fk",
"tableFrom": "space_members",
"tableTo": "browser_devices",
"columnsFrom": [
"deviceId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transfer_requests": {
"name": "transfer_requests",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"mode": {
"name": "mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"senderGuestId": {
"name": "senderGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"recipientGuestId": {
"name": "recipientGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"byteSize": {
"name": "byteSize",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"acceptedAt": {
"name": "acceptedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"startBy": {
"name": "startBy",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"requests_sender_time": {
"name": "requests_sender_time",
"columns": [
"senderGuestId",
"createdAt"
],
"isUnique": false
},
"requests_recipient_time": {
"name": "requests_recipient_time",
"columns": [
"recipientGuestId",
"createdAt"
],
"isUnique": false
},
"requests_expiry": {
"name": "requests_expiry",
"columns": [
"status",
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"transfer_requests_senderGuestId_guest_sessions_id_fk": {
"name": "transfer_requests_senderGuestId_guest_sessions_id_fk",
"tableFrom": "transfer_requests",
"tableTo": "guest_sessions",
"columnsFrom": [
"senderGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"transfer_requests_recipientGuestId_guest_sessions_id_fk": {
"name": "transfer_requests_recipientGuestId_guest_sessions_id_fk",
"tableFrom": "transfer_requests",
"tableTo": "guest_sessions",
"columnsFrom": [
"recipientGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"request_different_guests": {
"name": "request_different_guests",
"value": "\"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\""
}
}
},
"connection_spaces": {
"name": "connection_spaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"creatorGuestId": {
"name": "creatorGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tokenHash": {
"name": "tokenHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"codeHash": {
"name": "codeHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approvalRequired": {
"name": "approvalRequired",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"closedAt": {
"name": "closedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connection_spaces_tokenHash_unique": {
"name": "connection_spaces_tokenHash_unique",
"columns": [
"tokenHash"
],
"isUnique": true
},
"connection_spaces_codeHash_unique": {
"name": "connection_spaces_codeHash_unique",
"columns": [
"codeHash"
],
"isUnique": true
},
"space_expiry": {
"name": "space_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"connection_spaces_creatorGuestId_guest_sessions_id_fk": {
"name": "connection_spaces_creatorGuestId_guest_sessions_id_fk",
"tableFrom": "connection_spaces",
"tableTo": "guest_sessions",
"columnsFrom": [
"creatorGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,901 @@
{
"version": "6",
"dialect": "sqlite",
"id": "583c8a7b-3fe7-47d9-b150-a93d5a7d8de3",
"prevId": "11892dda-406f-413c-ba07-9b439d0549db",
"tables": {
"item_access": {
"name": "item_access",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"itemId": {
"name": "itemId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"deletedAt": {
"name": "deletedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"access_subject_role": {
"name": "access_subject_role",
"columns": [
"itemId",
"guestId",
"role"
],
"isUnique": true
},
"access_guest": {
"name": "access_guest",
"columns": [
"guestId",
"deletedAt"
],
"isUnique": false
}
},
"foreignKeys": {
"item_access_itemId_items_id_fk": {
"name": "item_access_itemId_items_id_fk",
"tableFrom": "item_access",
"tableTo": "items",
"columnsFrom": [
"itemId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"item_access_guestId_guest_sessions_id_fk": {
"name": "item_access_guestId_guest_sessions_id_fk",
"tableFrom": "item_access",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transfer_batches": {
"name": "transfer_batches",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"requestId": {
"name": "requestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"storedAt": {
"name": "storedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"transfer_batches_requestId_unique": {
"name": "transfer_batches_requestId_unique",
"columns": [
"requestId"
],
"isUnique": true
}
},
"foreignKeys": {
"transfer_batches_requestId_transfer_requests_id_fk": {
"name": "transfer_batches_requestId_transfer_requests_id_fk",
"tableFrom": "transfer_batches",
"tableTo": "transfer_requests",
"columnsFrom": [
"requestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"guest_blocks": {
"name": "guest_blocks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"targetGuestId": {
"name": "targetGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"guest_block_pair": {
"name": "guest_block_pair",
"columns": [
"guestId",
"targetGuestId"
],
"isUnique": true
}
},
"foreignKeys": {
"guest_blocks_guestId_guest_sessions_id_fk": {
"name": "guest_blocks_guestId_guest_sessions_id_fk",
"tableFrom": "guest_blocks",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"guest_blocks_targetGuestId_guest_sessions_id_fk": {
"name": "guest_blocks_targetGuestId_guest_sessions_id_fk",
"tableFrom": "guest_blocks",
"tableTo": "guest_sessions",
"columnsFrom": [
"targetGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"browser_devices": {
"name": "browser_devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"discoverable": {
"name": "discoverable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"lastSeen": {
"name": "lastSeen",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"browser_devices_guestId_unique": {
"name": "browser_devices_guestId_unique",
"columns": [
"guestId"
],
"isUnique": true
},
"browser_devices_code_unique": {
"name": "browser_devices_code_unique",
"columns": [
"code"
],
"isUnique": true
}
},
"foreignKeys": {
"browser_devices_guestId_guest_sessions_id_fk": {
"name": "browser_devices_guestId_guest_sessions_id_fk",
"tableFrom": "browser_devices",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"device_name_length": {
"name": "device_name_length",
"value": "length(\"browser_devices\".\"name\") between 1 and 40"
}
}
},
"guest_sessions": {
"name": "guest_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tokenHash": {
"name": "tokenHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"revokedAt": {
"name": "revokedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"guest_sessions_tokenHash_unique": {
"name": "guest_sessions_tokenHash_unique",
"columns": [
"tokenHash"
],
"isUnique": true
},
"guest_expiry": {
"name": "guest_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"idempotency_records": {
"name": "idempotency_records",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guestId": {
"name": "guestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payloadHash": {
"name": "payloadHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"response": {
"name": "response",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idempotency_scope": {
"name": "idempotency_scope",
"columns": [
"guestId",
"operation",
"key"
],
"isUnique": true
}
},
"foreignKeys": {
"idempotency_records_guestId_guest_sessions_id_fk": {
"name": "idempotency_records_guestId_guest_sessions_id_fk",
"tableFrom": "idempotency_records",
"tableTo": "guest_sessions",
"columnsFrom": [
"guestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"items": {
"name": "items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"batchId": {
"name": "batchId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"items_expiry": {
"name": "items_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"items_batchId_transfer_batches_id_fk": {
"name": "items_batchId_transfer_batches_id_fk",
"tableFrom": "items",
"tableTo": "transfer_batches",
"columnsFrom": [
"batchId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"item_payload": {
"name": "item_payload",
"value": "(\"items\".\"kind\" = 'text' AND \"items\".\"text\" IS NOT NULL AND \"items\".\"url\" IS NULL) OR (\"items\".\"kind\" = 'url' AND \"items\".\"url\" IS NOT NULL AND \"items\".\"text\" IS NULL)"
}
}
},
"space_members": {
"name": "space_members",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"spaceId": {
"name": "spaceId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deviceId": {
"name": "deviceId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"joinedAt": {
"name": "joinedAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"space_device": {
"name": "space_device",
"columns": [
"spaceId",
"deviceId"
],
"isUnique": true
}
},
"foreignKeys": {
"space_members_spaceId_connection_spaces_id_fk": {
"name": "space_members_spaceId_connection_spaces_id_fk",
"tableFrom": "space_members",
"tableTo": "connection_spaces",
"columnsFrom": [
"spaceId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"space_members_deviceId_browser_devices_id_fk": {
"name": "space_members_deviceId_browser_devices_id_fk",
"tableFrom": "space_members",
"tableTo": "browser_devices",
"columnsFrom": [
"deviceId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"transfer_requests": {
"name": "transfer_requests",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"mode": {
"name": "mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"senderGuestId": {
"name": "senderGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"recipientGuestId": {
"name": "recipientGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"byteSize": {
"name": "byteSize",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"acceptedAt": {
"name": "acceptedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rejectedAt": {
"name": "rejectedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"startBy": {
"name": "startBy",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"requests_sender_time": {
"name": "requests_sender_time",
"columns": [
"senderGuestId",
"createdAt"
],
"isUnique": false
},
"requests_recipient_time": {
"name": "requests_recipient_time",
"columns": [
"recipientGuestId",
"createdAt"
],
"isUnique": false
},
"requests_expiry": {
"name": "requests_expiry",
"columns": [
"status",
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"transfer_requests_senderGuestId_guest_sessions_id_fk": {
"name": "transfer_requests_senderGuestId_guest_sessions_id_fk",
"tableFrom": "transfer_requests",
"tableTo": "guest_sessions",
"columnsFrom": [
"senderGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"transfer_requests_recipientGuestId_guest_sessions_id_fk": {
"name": "transfer_requests_recipientGuestId_guest_sessions_id_fk",
"tableFrom": "transfer_requests",
"tableTo": "guest_sessions",
"columnsFrom": [
"recipientGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"request_different_guests": {
"name": "request_different_guests",
"value": "\"transfer_requests\".\"senderGuestId\" <> \"transfer_requests\".\"recipientGuestId\""
}
}
},
"connection_spaces": {
"name": "connection_spaces",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"creatorGuestId": {
"name": "creatorGuestId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tokenHash": {
"name": "tokenHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"codeHash": {
"name": "codeHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approvalRequired": {
"name": "approvalRequired",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"closedAt": {
"name": "closedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"connection_spaces_tokenHash_unique": {
"name": "connection_spaces_tokenHash_unique",
"columns": [
"tokenHash"
],
"isUnique": true
},
"connection_spaces_codeHash_unique": {
"name": "connection_spaces_codeHash_unique",
"columns": [
"codeHash"
],
"isUnique": true
},
"space_expiry": {
"name": "space_expiry",
"columns": [
"expiresAt"
],
"isUnique": false
}
},
"foreignKeys": {
"connection_spaces_creatorGuestId_guest_sessions_id_fk": {
"name": "connection_spaces_creatorGuestId_guest_sessions_id_fk",
"tableFrom": "connection_spaces",
"tableTo": "guest_sessions",
"columnsFrom": [
"creatorGuestId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1791445972914,
"tag": "0000_aberrant_scourge",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1791447103463,
"tag": "0001_numerous_dust",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1791447352994,
"tag": "0002_orange_norrin_radd",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1791448071863,
"tag": "0003_spooky_blob",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1791449604794,
"tag": "0004_bright_wildside",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1791450987520,
"tag": "0005_tan_miracleman",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1791453590596,
"tag": "0006_file_url_only",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1791770452198,
"tag": "0007_rapid_morph",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1791770486677,
"tag": "0008_temporary-retention",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1791770606214,
"tag": "0009_vault-note",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1791771685461,
"tag": "0010_single-room",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1791771741603,
"tag": "0011_single-room-index",
"breakpoints": true
}
]
}

32
apps/server/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@hop/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/main.js",
"test": "node --import tsx --test test/*.test.ts test/*.test.js",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^10.1.5",
"@hop/shared": "0.1.0",
"argon2": "^0.44.0",
"better-sqlite3": "^12.6.2",
"drizzle-orm": "^0.45.3",
"fastify": "^5.12.5",
"ipaddr.js": "^2.3.0",
"sharp": "^0.35.5",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "^0.31.9",
"socket.io-client": "^4.8.3",
"tsx": "^4.21.0"
}
}

667
apps/server/src/accounts.ts Normal file
View File

@@ -0,0 +1,667 @@
import { randomUUID } from 'node:crypto';
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
import { parseUrls, type AccountItem, type EmailRequest } from '@hop/shared';
import type { Service, RequestRow, Guest } from './service.js';
import type { User } from './auth.js';
import * as s from './db/schema.js';
import { fail, hash } from './security.js';
export class Accounts {
constructor(readonly service: Service) {}
verified(userId: string) {
const user = this.service.auth.user(userId);
if (!this.service.auth.serialize(user).verified)
fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.');
return user;
}
notify(userId: string) {
const io = this.service.io;
if (!io) return;
for (const socket of io.sockets.sockets.values()) {
if (socket.data.accountId !== userId) continue;
const session = this.service.db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, socket.data.accountSessionId))
.get();
const user = this.service.auth.user(userId);
if (
!session ||
session.revokedAt ||
session.expiresAt <= Date.now() ||
!['pending', 'active'].includes(user.status)
) {
socket.leave(`account:${userId}`);
socket.emit('account:revoked', {});
}
}
io.to(`account:${userId}`).emit('inbox:updated', {});
}
request(userId: string, id: string) {
const row = this.service.db
.select()
.from(s.requests)
.where(
and(
eq(s.requests.id, id),
eq(s.requests.mode, 'email'),
inArray(s.requests.kind, ['url', 'file']),
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
),
)
.get();
if (!row) fail(404, 'REQUEST_NOT_FOUND', '전송 요청을 찾을 수 없습니다.');
return row!;
}
policy(senderId: string, recipientId: string) {
const recipient = this.verified(recipientId);
this.verified(senderId);
if (senderId === recipientId) return 'allow';
const rule = this.service.db
.select()
.from(s.rules)
.where(and(eq(s.rules.userId, recipientId), eq(s.rules.targetUserId, senderId)))
.get();
if (rule?.action === 'block' || recipient.receivePolicy === 'none') return 'block';
if (rule?.action === 'allow') return 'allow';
if (recipient.receivePolicy === 'allowed') return 'block';
return 'request';
}
canTransfer(row: RequestRow) {
try {
return (
row.mode === 'email' &&
row.status === 'accepted' &&
this.policy(row.senderUserId!, row.recipientUserId!) !== 'block'
);
} catch {
return false;
}
}
idem<T>(
userId: string,
operation: string,
key: string | undefined,
payload: unknown,
fn: () => T,
): T {
if (!key || !/^[A-Za-z0-9_-]{8,128}$/.test(key))
fail(400, 'IDEMPOTENCY_REQUIRED', '유효한 중복 방지 키가 필요합니다.');
const payloadHash = hash(JSON.stringify(payload));
return this.service.db.transaction(() => {
const row = this.service.db
.select()
.from(s.idempotency)
.where(
and(
eq(s.idempotency.userId, userId),
eq(s.idempotency.operation, operation),
eq(s.idempotency.key, key!),
),
)
.get();
if (row) {
if (row.payloadHash !== payloadHash)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 요청 키의 내용이 달라졌습니다.');
return JSON.parse(row.response) as T;
}
const result = fn();
this.service.db
.insert(s.idempotency)
.values({
id: randomUUID(),
userId,
operation,
key: key!,
payloadHash,
response: JSON.stringify(result),
createdAt: Date.now(),
})
.run();
return result;
});
}
create(
userId: string,
input: {
email: string;
kind: 'url' | 'file';
byteSize: number;
count: number;
files?: { name: string; size: number; type: string }[];
},
) {
const { db, cfg, limiter } = this.service;
this.verified(userId);
limiter.hit(`remote-target:${userId}`, 30, 3600000);
const recipient = db
.select()
.from(s.users)
.where(
and(
eq(s.users.email, input.email),
eq(s.users.status, 'active'),
sql`${s.users.verifiedAt} IS NOT NULL`,
),
)
.get();
if (!recipient) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
let policy: string;
try {
policy = this.policy(userId, recipient!.id);
} catch {
policy = 'block';
}
if (policy === 'block') fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
if (
input.count > cfg.maxItems ||
(input.kind !== 'file' && input.byteSize > cfg.maxUrlBytes) ||
input.files?.some((f) => f.size > cfg.maxFileBytes)
)
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
if (policy === 'request') {
limiter.hit(`remote-new:${userId}`, 20, 86400000);
const pending = db
.select()
.from(s.requests)
.where(
and(
eq(s.requests.recipientUserId, recipient!.id),
eq(s.requests.status, 'pending'),
gt(s.requests.expiresAt, Date.now()),
),
)
.all();
if (pending.length >= 50) fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
}
const now = Date.now(),
id = randomUUID();
db.insert(s.requests)
.values({
id,
mode: 'email',
senderUserId: userId,
recipientUserId: recipient!.id,
kind: input.kind,
byteSize: input.byteSize,
count: input.count,
manifest: input.files ? JSON.stringify(input.files) : null,
status: policy === 'allow' ? 'accepted' : 'pending',
createdAt: now,
expiresAt: now + cfg.emailRequestDays * 86400000,
acceptedAt: policy === 'allow' ? now : null,
startBy: policy === 'allow' ? now + cfg.uploadStartHours * 3600000 : null,
})
.run();
db.insert(s.batches).values({ id: randomUUID(), requestId: id, status: 'waiting' }).run();
if (input.files) {
this.service.storage.createManifest({ id: userId, kind: 'user' }, id, input.files);
if (policy === 'allow') this.service.storage.reserve({ id: userId, kind: 'user' }, id);
}
return { requestId: id, requiresAcceptance: policy !== 'allow' };
}
accept(userId: string, id: string, allowSender = false) {
this.verified(userId);
const row = this.request(userId, id);
if (row.recipientUserId !== userId) fail(403, 'FORBIDDEN', '수신 계정만 수락할 수 있습니다.');
if (
row.status !== 'pending' ||
row.expiresAt <= Date.now() ||
this.policy(row.senderUserId!, userId) === 'block'
)
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
if (row.kind === 'file')
this.service.storage.reserve({ id: row.senderUserId!, kind: 'user' }, id);
if (allowSender) this.rule(userId, row.senderUserId!, 'allow');
const now = Date.now();
this.service.db
.update(s.requests)
.set({
status: 'accepted',
acceptedAt: now,
startBy: now + this.service.cfg.uploadStartHours * 3600000,
})
.where(eq(s.requests.id, id))
.run();
return { requestId: id };
}
reject(userId: string, id: string) {
this.verified(userId);
const row = this.request(userId, id);
if (row.recipientUserId !== userId || row.status !== 'pending')
fail(409, 'REQUEST_STATE', '거절할 수 없는 요청입니다.');
this.service.db
.update(s.requests)
.set({ status: 'rejected', rejectedAt: Date.now() })
.where(eq(s.requests.id, id))
.run();
}
cancel(userId: string, id: string) {
const row = this.request(userId, id),
batch = this.service.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!;
if (
row.senderUserId !== userId ||
!['pending', 'accepted'].includes(row.status) ||
batch.status === 'stored'
)
fail(409, 'REQUEST_STATE', '취소할 수 없는 요청입니다.');
this.service.storage.release(id);
this.service.db
.update(s.requests)
.set({ status: 'cancelled' })
.where(eq(s.requests.id, id))
.run();
this.service.db
.update(s.batches)
.set({ status: 'failed' })
.where(eq(s.batches.id, batch.id))
.run();
}
complete(userId: string, id: string, input: { urls: string[] }) {
const row = this.request(userId, id);
if (
row.senderUserId !== userId ||
!this.canTransfer(row) ||
!row.startBy ||
row.startBy <= Date.now()
)
fail(409, 'REQUEST_STATE', '수락된 유효한 요청이 필요합니다.');
if (row.kind === 'file')
fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.');
const batch = this.service.db
.select()
.from(s.batches)
.where(eq(s.batches.requestId, id))
.get()!;
if (batch.status !== 'waiting') fail(409, 'TRANSFER_STATE', '이미 처리된 전송입니다.');
if (row.kind !== 'url')
fail(400, 'TYPE_UNSUPPORTED', '파일과 HTTP/HTTPS 링크만 보낼 수 있습니다.');
const parsed = parseUrls(input.urls.join('\n'));
if (parsed.invalid.length || parsed.urls.length !== input.urls.length)
fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.');
const values = parsed.urls;
if (Buffer.byteLength(values.join('\n')) !== row.byteSize || values.length !== row.count)
fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.');
const now = Date.now();
for (const value of values) {
const itemId = randomUUID();
this.service.db
.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: row.kind,
url: value,
createdAt: now,
expiresAt: now + this.service.cfg.urlRetentionDays * 86400000,
})
.run();
this.createAccess(row, itemId);
}
this.service.db
.update(s.batches)
.set({ status: 'stored', storedAt: now })
.where(eq(s.batches.id, batch.id))
.run();
return { requestId: id, batchId: batch.id };
}
createAccess(row: RequestRow, itemId: string) {
if (row.mode === 'local') {
for (const [guestId, role] of [
[row.senderGuestId!, 'sender'],
[row.recipientGuestId!, 'recipient'],
] as const)
this.service.db
.insert(s.access)
.values({ id: randomUUID(), itemId, guestId, role, read: false })
.run();
this.service.vault.receipt(row, itemId);
} else if (row.senderUserId === row.recipientUserId)
this.service.db
.insert(s.access)
.values({
id: randomUUID(),
itemId,
userId: row.senderUserId!,
role: 'self',
read: false,
note: row.note,
})
.run();
else
for (const [userId, role] of [
[row.senderUserId!, 'sender'],
[row.recipientUserId!, 'recipient'],
] as const)
this.service.db
.insert(s.access)
.values({ id: randomUUID(), itemId, userId, role, read: false })
.run();
}
listRequests(userId: string): EmailRequest[] {
this.verified(userId);
return this.service.db
.select({ request: s.requests, batch: s.batches })
.from(s.requests)
.innerJoin(s.batches, eq(s.requests.id, s.batches.requestId))
.where(
and(
eq(s.requests.mode, 'email'),
gt(s.requests.createdAt, Date.now() - 7 * 86400000),
inArray(s.requests.kind, ['url', 'file']),
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
),
)
.orderBy(desc(s.requests.createdAt), desc(s.requests.id))
.limit(100)
.all()
.map(({ request: r, batch: b }) => ({
id: r.id,
batchId: b.id,
kind: r.kind as 'url' | 'file',
byteSize: r.byteSize,
count: r.count,
status: r.status,
transferStatus: b.status,
createdAt: r.createdAt,
expiresAt: r.expiresAt,
startBy: r.startBy,
direction: r.senderUserId === userId ? 'sent' : 'received',
sender: this.service.auth.serialize(this.service.auth.user(r.senderUserId!)),
recipient: this.service.auth.serialize(this.service.auth.user(r.recipientUserId!)),
files:
r.kind === 'file'
? this.service.storage.fileRows(r.id).map((f) => ({
id: f.id,
name: f.name,
size: f.size,
type: f.type,
status: f.status,
}))
: undefined,
}));
}
access(userId: string, id: string) {
this.verified(userId);
const row = this.service.db
.select({ access: s.access, item: s.items })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.access.id, id),
eq(s.access.userId, userId),
inArray(s.items.kind, ['url', 'file']),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
),
)
.get();
if (!row) fail(404, 'ITEM_NOT_FOUND', '자료가 없거나 만료되었습니다.');
return row!;
}
read(userId: string, id: string, value: boolean) {
const row = this.access(userId, id);
this.service.db
.update(s.access)
.set({ read: value })
.where(and(eq(s.access.itemId, row.item.id), eq(s.access.role, row.access.role)))
.run();
for (const access of this.service.db
.select()
.from(s.access)
.where(eq(s.access.itemId, row.item.id))
.all()) {
if (access.userId) this.notify(access.userId);
if (access.guestId) this.service.presence.emit(access.guestId, 'access:updated', {});
}
}
list(
userId: string,
view: 'inbox' | 'vault' | 'sent' | 'favorites' | 'contents',
query: { cursor?: string; limit: number; search?: string; kind?: string; read?: boolean },
) {
this.verified(userId);
let after: { createdAt: number; id: string } | undefined;
if (query.cursor) {
try {
after = JSON.parse(Buffer.from(query.cursor, 'base64url').toString());
if (!after || !Number.isSafeInteger(after.createdAt) || typeof after.id !== 'string')
throw Error();
} catch {
fail(400, 'INVALID_CURSOR', '목록 커서가 올바르지 않습니다.');
}
}
const rows = this.service.db
.select({ access: s.access, item: s.items, request: s.requests })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.innerJoin(s.batches, eq(s.items.batchId, s.batches.id))
.innerJoin(s.requests, eq(s.batches.requestId, s.requests.id))
.where(
and(
eq(s.access.userId, userId),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
view === 'contents'
? inArray(s.access.role, ['self', 'recipient'])
: view === 'favorites'
? eq(s.access.favorite, true)
: eq(
s.access.role,
view === 'inbox' ? 'recipient' : view === 'vault' ? 'self' : 'sender',
),
inArray(s.items.kind, ['url', 'file']),
query.kind ? eq(s.items.kind, query.kind as any) : undefined,
query.read !== undefined ? eq(s.access.read, query.read) : undefined,
query.search
? sql`(instr(lower(coalesce(${s.access.note},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.url},'')),lower(${query.search})) > 0 OR EXISTS (SELECT 1 FROM files f WHERE f.id = ${s.items.fileId} AND instr(lower(f.name),lower(${query.search})) > 0))`
: undefined,
after
? or(
lt(s.items.createdAt, after.createdAt),
and(eq(s.items.createdAt, after.createdAt), lt(s.access.id, after.id)),
)
: undefined,
),
)
.orderBy(desc(s.items.createdAt), desc(s.access.id))
.limit(query.limit + 1)
.all();
const result: AccountItem[] = rows.slice(0, query.limit).map((r) => {
const peerId =
r.access.role === 'sender' ? r.request.recipientUserId : r.request.senderUserId;
const peer = peerId ? this.service.auth.user(peerId) : null;
const file = r.item.fileId
? this.service.db.select().from(s.files).where(eq(s.files.id, r.item.fileId)).get()
: null;
const recipient = this.service.db
.select()
.from(s.access)
.where(and(eq(s.access.itemId, r.item.id), eq(s.access.role, 'recipient')))
.get();
return {
accessId: r.access.id,
id: r.item.id,
batchId: r.item.batchId,
kind: r.item.kind as 'url' | 'file',
url: r.item.url,
role: r.access.role,
read: r.access.read,
favorite: r.access.favorite,
note: r.access.note,
file: file ? { id: file.id, name: file.name, size: file.size, type: file.type } : undefined,
peerName:
peer?.displayName ??
(r.request.senderGuestId
? this.service.device(r.request.senderGuestId).name
: '익명 기기'),
peerCode: '',
senderEmail: peer?.email ?? undefined,
senderName: peer?.displayName,
createdAt: r.item.createdAt,
expiresAt: r.item.expiresAt,
recipientRead: recipient?.read ?? false,
};
});
const last = result.at(-1);
return {
items: result,
nextCursor:
rows.length > query.limit && last
? Buffer.from(JSON.stringify({ createdAt: last.createdAt, id: last.accessId })).toString(
'base64url',
)
: null,
};
}
used(userId: string) {
const db = this.service.db;
const owned = db
.select()
.from(s.files)
.where(and(eq(s.files.ownerUserId, userId), inArray(s.files.status, ['stored', 'cleaning'])))
.all();
const accounted = new Map(owned.map((f) => [f.id, f.size]));
const preserved = db
.select({ file: s.files })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.innerJoin(s.files, eq(s.items.fileId, s.files.id))
.where(
and(
eq(s.access.userId, userId),
or(eq(s.access.favorite, true), eq(s.access.role, 'self')),
isNull(s.access.deletedAt),
gt(s.items.expiresAt, Date.now()),
),
)
.all();
for (const { file } of preserved) accounted.set(file.id, file.size);
return [...accounted.values()].reduce((a, b) => a + b, 0);
}
ensureQuota(userId: string, bytes: number) {
if (this.used(userId) + bytes > this.service.cfg.userQuota)
fail(413, 'ACCOUNT_QUOTA', '계정 저장 용량을 초과했습니다.');
}
favorite(userId: string, id: string, value: boolean) {
this.service.db.transaction(() => {
const row = this.access(userId, id);
if (value && !row.access.favorite && row.item.fileId) {
const file = this.service.db
.select()
.from(s.files)
.where(eq(s.files.id, row.item.fileId))
.get()!;
if (file.ownerUserId !== userId && row.access.role !== 'self')
this.ensureQuota(userId, file.size);
}
this.service.db.update(s.access).set({ favorite: value }).where(eq(s.access.id, id)).run();
});
this.notify(userId);
}
move(g: Guest, userId: string, id: string) {
this.verified(userId);
return this.service.db.transaction(() => {
const row = this.service.accessDetail(g, id);
if (row.item.fileId) {
const file = this.service.db
.select()
.from(s.files)
.where(eq(s.files.id, row.item.fileId))
.get()!;
this.ensureQuota(userId, file.size);
if (file.ownerGuestId === g.id)
this.service.db
.update(s.files)
.set({ ownerGuestId: null, ownerUserId: userId })
.where(eq(s.files.id, file.id))
.run();
}
const existing = this.service.db
.select()
.from(s.access)
.where(
and(
eq(s.access.itemId, row.item.id),
eq(s.access.userId, userId),
eq(s.access.role, 'self'),
),
)
.get();
if (existing)
this.service.db
.update(s.access)
.set({ deletedAt: null })
.where(eq(s.access.id, existing.id))
.run();
else
this.service.db
.insert(s.access)
.values({
id: randomUUID(),
itemId: row.item.id,
userId,
role: 'self',
read: row.access.read,
})
.run();
this.service.db
.update(s.access)
.set({ deletedAt: Date.now() })
.where(eq(s.access.id, row.access.id))
.run();
return { ok: true };
});
}
rule(userId: string, target: string, action: 'allow' | 'block') {
this.verified(userId);
if (target === userId)
fail(400, 'RULE_INVALID', '자기 계정에는 수신 규칙을 적용하지 않습니다.');
const candidate = this.service.db
.select()
.from(s.requests)
.where(and(eq(s.requests.senderUserId, target), eq(s.requests.recipientUserId, userId)))
.get();
const existing = this.service.db
.select()
.from(s.rules)
.where(and(eq(s.rules.userId, userId), eq(s.rules.targetUserId, target)))
.get();
if (!candidate && !existing)
fail(404, 'SENDER_NOT_FOUND', '수신 관계가 있는 발신자만 관리할 수 있습니다.');
this.service.db
.insert(s.rules)
.values({ id: randomUUID(), userId, targetUserId: target, action })
.onConflictDoUpdate({ target: [s.rules.userId, s.rules.targetUserId], set: { action } })
.run();
if (action === 'block') {
const affected = this.service.db
.select({ request: s.requests, batch: s.batches })
.from(s.requests)
.innerJoin(s.batches, eq(s.requests.id, s.batches.requestId))
.where(
and(
eq(s.requests.senderUserId, target),
eq(s.requests.recipientUserId, userId),
inArray(s.requests.status, ['pending', 'accepted']),
inArray(s.batches.status, ['waiting', 'uploading']),
),
)
.all();
for (const { request } of affected) {
this.service.storage.release(request.id);
this.service.db
.update(s.requests)
.set({
status: request.status === 'pending' ? 'rejected' : 'cancelled',
rejectedAt: Date.now(),
})
.where(eq(s.requests.id, request.id))
.run();
}
this.notify(target);
}
this.notify(userId);
}
}

828
apps/server/src/app.ts Normal file
View File

@@ -0,0 +1,828 @@
import Fastify, { LogController, type FastifyRequest } from 'fastify';
class PrivateLogs extends LogController {
constructor() {
super({ disableRequestLogging: true });
}
}
import cookie from '@fastify/cookie';
import multipart from '@fastify/multipart';
import { createReadStream } from 'node:fs';
import serveStatic from '@fastify/static';
import { Server } from 'socket.io';
import { timingSafeEqual } from 'node:crypto';
import { existsSync } from 'node:fs';
import { z } from 'zod';
import {
devicePatch,
requestInput,
spaceInput,
joinInput,
completeInput,
emailRequestInput,
} from '@hop/shared';
import { createResendMailer } from './mail.js';
import { Auth, normalizeEmail, type Mailer } from './auth.js';
import { Accounts } from './accounts.js';
import * as tables from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { StorageSubject } from './storage.js';
import type { Config } from './config.js';
import { openDatabase } from './db/index.js';
import { Service, type Guest } from './service.js';
import { Presence } from './presence.js';
import { ApiError, fail, clientIp } from './security.js';
export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) {
const app = Fastify({
logger: cfg.production
? {
level: 'info',
redact: ['req.headers.cookie', 'req.headers.authorization', 'req.url'],
serializers: { req: (r) => ({ method: r.method }) },
}
: false,
logController: new PrivateLogs(),
trustProxy: false,
bodyLimit: cfg.maxUrlBytes * 6 + 16384,
});
await app.register(cookie);
await app.register(multipart, {
limits: { fileSize: cfg.maxFileBytes + 1, files: 1, fields: 0, parts: 1 },
});
const { db, sqlite } = openDatabase(cfg.databasePath),
service = new Service(db, cfg);
let devOrigin: string | undefined;
const allowed = (origin: string | undefined) =>
Boolean(
origin &&
(origin === new URL(cfg.appUrl).origin || (!cfg.production && origin === devOrigin)),
);
const ip = (req: FastifyRequest) =>
clientIp(req.raw.socket.remoteAddress, req.headers, cfg.trustedProxies);
const io = new Server(app.server, {
serveClient: false,
maxHttpBufferSize: 16384,
allowRequest: (req, callback) => {
// Browsers omit Origin for same-origin polling GETs. Fetch Metadata is browser-owned;
// cookie authentication and the session-bound CSRF token are also required by io.use.
const sameOriginFetch =
!req.headers.origin && req.headers['sec-fetch-site'] === 'same-origin';
callback(null, allowed(req.headers.origin) || sameOriginFetch);
},
});
const presence = new Presence(io, cfg, () => service.refresh());
service.presence = presence;
service.io = io;
service.auth = new Auth(service, options.mailer ?? createResendMailer(cfg));
service.accounts = new Accounts(service);
io.use((socket, next) => {
try {
const token = app.parseCookie(socket.request.headers.cookie ?? '').hop_guest;
const guest = service.guest(token);
if (!guest || socket.handshake.auth.csrfToken !== service.csrf(token!))
return next(new Error('SESSION_EXPIRED'));
socket.data.guest = guest;
const account = service.auth.session(
app.parseCookie(socket.request.headers.cookie ?? '').hop_account,
);
if (account) {
socket.data.accountId = account.user.id;
socket.data.accountSessionId = account.session.id;
}
socket.data.ip = clientIp(
socket.request.socket.remoteAddress,
socket.request.headers,
cfg.trustedProxies,
);
next();
} catch {
next(new Error('SESSION_EXPIRED'));
}
});
io.on('connection', (socket) => {
presence.connect((socket.data.guest as Guest).id, socket, socket.data.ip);
if (socket.data.accountId) {
socket.join(`account:${socket.data.accountId}`);
socket.join(`account-session:${socket.data.accountSessionId}`);
}
});
const auth = (req: FastifyRequest) => {
const guest = service.guest(req.cookies.hop_guest);
if (!guest) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다. 새로 접속해 주세요.');
return guest!;
};
const account = (req: FastifyRequest, verified = false) =>
service.auth.require(req.cookies.hop_account, verified);
const requestSubject = (req: FastifyRequest, id: string): StorageSubject => {
const row = db.select().from(tables.requests).where(eq(tables.requests.id, id)).get();
const user = service.auth.session(req.cookies.hop_account)?.user;
if (
row?.mode === 'email' &&
user &&
(row.senderUserId === user.id || row.recipientUserId === user.id)
) {
account(req, true);
return { id: user.id, kind: 'user', sessionId: account(req, true).session.id };
}
return auth(req);
};
const accessSubject = (req: FastifyRequest, id: string): StorageSubject => {
const row = db.select().from(tables.access).where(eq(tables.access.id, id)).get(),
user = service.auth.session(req.cookies.hop_account)?.user;
if (user && row?.userId === user.id) {
account(req, true);
return { id: user.id, kind: 'user', sessionId: account(req, true).session.id };
}
return auth(req);
};
const readAccess = (subject: StorageSubject, id: string, value: boolean) => {
if (subject.kind === 'user') service.accounts.read(subject.id, id, value);
else service.read(subject as Guest, id, value);
};
const csrf = (req: FastifyRequest) => {
const token = req.cookies.hop_guest;
const actual = req.headers['x-csrf-token'];
if (!token || typeof actual !== 'string')
fail(403, 'CSRF_INVALID', '요청을 확인할 수 없습니다. 새로고침해 주세요.');
const expected = Buffer.from(service.csrf(token!));
const given = Buffer.from(actual as string);
if (given.length !== expected.length || !timingSafeEqual(given, expected))
fail(403, 'CSRF_INVALID', '요청을 확인할 수 없습니다.');
};
const key = (req: FastifyRequest) =>
typeof req.headers['idempotency-key'] === 'string' ? req.headers['idempotency-key'] : undefined;
const id = (req: FastifyRequest) => z.object({ id: z.uuid() }).parse(req.params).id;
app.addHook('onRequest', async (req, reply) => {
reply
.header('X-Content-Type-Options', 'nosniff')
.header('Referrer-Policy', 'no-referrer')
.header('X-Frame-Options', 'DENY')
.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), bluetooth=()');
reply.header(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
);
if (req.url.startsWith('/api/')) {
reply.header('Cache-Control', 'no-store');
service.limiter.hit(`api-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 240);
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
if (!allowed(req.headers.origin))
fail(403, 'ORIGIN_INVALID', '허용되지 않은 접속 경로입니다.');
if (req.url.split('?')[0] !== '/api/guest/session') {
auth(req);
csrf(req);
}
}
}
});
app.setErrorHandler((err, req, reply) => {
let status = 500,
code = 'INTERNAL_ERROR',
message = '처리 중 오류가 발생했습니다.';
if (err instanceof ApiError) {
status = err.status;
code = err.code;
message = err.message;
} else if (err instanceof z.ZodError) {
status = 400;
code = 'VALIDATION_ERROR';
message = '입력 내용을 확인해 주세요.';
} else if (
err &&
typeof err === 'object' &&
'statusCode' in err &&
typeof err.statusCode === 'number' &&
err.statusCode < 500
) {
status = err.statusCode;
code = 'INVALID_REQUEST';
message = '요청 형식이나 크기를 확인해 주세요.';
}
reply.status(status).send({ code, message, requestId: req.id });
});
const setCookie = (reply: any, token: string, expiresAt: number) =>
reply.setCookie('hop_guest', token, {
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
path: '/',
expires: new Date(expiresAt),
});
app.get('/api/health', async () => ({ status: 'ok', phase: 1 }));
app.post('/api/guest/session', async (req, reply) => {
service.limiter.hit(`session-ip:${ip(req) ?? req.raw.socket.remoteAddress}`, 20);
let token = req.cookies.hop_guest,
guest = service.guest(token);
if (!guest) {
const created = service.createGuest(req.headers['user-agent'] ?? '');
token = created.token;
guest = created.guest;
}
setCookie(reply, token!, guest.expiresAt);
return {
device: service.device(guest.id),
csrfToken: service.csrf(token!),
expiresAt: guest.expiresAt,
};
});
app.get('/api/device/snapshot', async (req) => {
const g = auth(req);
return db.transaction(() => {
const r = service.listRequests(g),
list = service.listItems(g);
return {
device: service.device(g.id),
devices: service.peers(g),
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
requests: r,
items: list.items,
nextCursor: list.nextCursor,
spaces: service.listSpaces(g),
};
});
});
app.get('/api/device/me', async (req) => ({
device: service.device(auth(req).id),
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
}));
app.patch('/api/device/me', async (req) => {
const guest = auth(req);
service.patchDevice(guest, devicePatch.parse(req.body));
return { device: service.device(guest.id) };
});
app.post('/api/device/reset', async (req, reply) => {
const guest = auth(req);
service.reset(guest);
const created = service.createGuest(req.headers['user-agent'] ?? '');
setCookie(reply, created.token, created.guest.expiresAt);
return {
device: service.device(created.guest.id),
csrfToken: service.csrf(created.token),
expiresAt: created.guest.expiresAt,
};
});
app.get('/api/discovery/devices', async (req) => ({
devices: service.peers(auth(req)),
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
}));
app.get('/api/spaces', async (req) => ({ spaces: service.listSpaces(auth(req)) }));
app.post('/api/spaces', async (req) =>
service.createSpace(auth(req), spaceInput.parse(req.body).approvalRequired),
);
app.post('/api/spaces/join', async (req) =>
service.joinSpace(auth(req), joinInput.parse(req.body), ip(req) ?? 'unknown'),
);
app.post('/api/spaces/:id/members/:memberId/approve', async (req) => {
const p = z.object({ id: z.uuid(), memberId: z.uuid() }).parse(req.params);
service.approve(auth(req), p.id, p.memberId);
return { ok: true };
});
app.delete('/api/spaces/:id/members/:memberId', async (req) => {
const p = z.object({ id: z.uuid(), memberId: z.uuid() }).parse(req.params);
service.remove(auth(req), p.id, p.memberId);
return { ok: true };
});
app.delete('/api/spaces/:id', async (req) => {
service.closeSpace(auth(req), id(req));
return { ok: true };
});
app.get('/api/transfer-requests', async (req) => {
const mode = (req.query as { mode?: string }).mode;
return {
requests:
mode === 'email'
? service.accounts.listRequests(account(req, true).user.id)
: service.listRequests(auth(req)),
};
});
app.post('/api/transfer-requests', async (req) => {
if ((req.body as { mode?: string })?.mode === 'email') {
const user = account(req, true).user,
input = emailRequestInput.parse(req.body);
input.email = normalizeEmail(input.email);
if (input.email !== user.email)
fail(410, 'EMAIL_TRANSFER_REMOVED', '이메일 전송 대신 내 임시 보관함을 이용해 주세요.');
const result = service.accounts.idem(user.id, 'request-create', key(req), input, () =>
service.accounts.create(user.id, input),
);
service.notifyTransfer(result.requestId);
return result;
}
const g = auth(req),
input = requestInput.parse(req.body);
const result = service.idem(g, 'request-create', key(req), input, () =>
service.createRequest(g, input, ip(req) ?? 'unknown'),
);
service.notifyTransfer(result.requestId);
return result;
});
app.post('/api/transfer-requests/:id/accept', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId);
let result;
if (subject.kind === 'user') {
const input = z
.object({ allowSender: z.boolean().default(false) })
.strict()
.parse(req.body);
result = service.accounts.idem(subject.id, `accept:${requestId}`, key(req), input, () =>
service.accounts.accept(subject.id, requestId, input.allowSender),
);
} else {
service.request(subject as Guest, requestId);
result = service.idem(subject as Guest, `accept:${requestId}`, key(req), {}, () =>
(() => {
const result = service.accept(subject as Guest, requestId);
service.vault.bindReceipt(requestId, service.auth.session(req.cookies.hop_account));
service.vault.deliver(requestId);
return result;
})(),
);
}
service.notifyTransfer(requestId);
return result;
});
app.post('/api/transfer-requests/:id/reject', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId);
if (subject.kind === 'user') service.accounts.reject(subject.id, requestId);
else service.reject(subject as Guest, requestId);
service.notifyTransfer(requestId);
return { ok: true };
});
app.post('/api/transfer-requests/:id/cancel', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId);
if (subject.kind === 'user') service.accounts.cancel(subject.id, requestId);
else service.cancel(subject as Guest, requestId);
service.notifyTransfer(requestId);
return { ok: true };
});
app.post('/api/transfer-requests/:id/block', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId);
if (subject.kind === 'user') {
const row = service.accounts.request(subject.id, requestId);
if (row.recipientUserId !== subject.id)
fail(403, 'FORBIDDEN', '수신 계정만 차단할 수 있습니다.');
service.accounts.rule(subject.id, row.senderUserId!, 'block');
} else service.block(subject as Guest, requestId);
return { ok: true };
});
app.post('/api/transfers/:id/complete', async (req) => {
const requestId = id(req),
subject = requestSubject(req, requestId),
input = completeInput.parse(req.body);
if (service.vault.source(requestId))
fail(409, 'VAULT_TRANSFER_MANAGED', '보관함 전송은 서버에서 처리합니다.');
let result;
if (subject.kind === 'user')
result = service.accounts.idem(subject.id, `complete:${requestId}`, key(req), input, () =>
service.accounts.complete(subject.id, requestId, input),
);
else {
service.request(subject as Guest, requestId);
result = service.idem(subject as Guest, `complete:${requestId}`, key(req), input, () =>
service.complete(subject as Guest, requestId, input),
);
}
service.notifyTransfer(requestId);
return result;
});
app.get('/api/device/items', async (req) => {
const q = z
.object({
cursor: z.string().max(512).optional(),
limit: z.coerce.number().int().min(1).max(100).default(30),
})
.parse(req.query);
return service.listItems(auth(req), q.cursor, q.limit);
});
app.get('/api/access/:id', async (req) => ({
item:
accessSubject(req, id(req)).kind === 'user'
? service.accounts.access(account(req, true).user.id, id(req)).item
: service.accessDetail(auth(req), id(req)).item,
}));
app.patch('/api/access/:id/read', async (req) => {
const body = z.object({ read: z.boolean() }).strict().parse(req.body);
readAccess(accessSubject(req, id(req)), id(req), body.read);
return { ok: true };
});
app.post('/api/uploads', async (req) => {
const input = z
.object({
requestId: z.uuid(),
fileIndex: z.coerce
.number()
.int()
.min(0)
.max(cfg.maxItems - 1),
})
.parse(req.query);
const uploadKey = z
.string()
.regex(/^[A-Za-z0-9_-]{8,128}$/)
.parse(key(req));
if (service.vault.source(input.requestId))
fail(409, 'VAULT_TRANSFER_MANAGED', '이미 저장된 파일을 전달합니다.');
return service.storage.upload(
requestSubject(req, input.requestId),
input.requestId,
input.fileIndex,
req,
uploadKey,
);
});
app.delete('/api/uploads/:id', async (req) => {
service.storage.cancel(
(() => {
const file = db
.select()
.from(tables.files)
.where(eq(tables.files.uploadId, id(req)))
.get();
const user = service.auth.session(req.cookies.hop_account)?.user;
return user && file?.ownerUserId === user.id
? { id: user.id, kind: 'user' as const }
: auth(req);
})(),
id(req),
);
return { ok: true };
});
app.get('/api/access/:id/download', async (req, reply) => {
const accessId = id(req),
g = accessSubject(req, accessId);
const { file, path } = service.storage.download(g, accessId);
const fallback =
file.name.replace(/[^a-zA-Z0-9._ -]/g, '_').replace(/^[. ]+/, '') || 'download';
const encoded = encodeURIComponent(file.name).replace(
/['()*]/g,
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
);
reply
.header(
'Content-Disposition',
`attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
)
.header('Content-Length', file.size)
.type('application/octet-stream');
readAccess(g, accessId, true);
return reply.send(createReadStream(path));
});
app.get('/api/access/:id/preview', async (req, reply) =>
reply
.type('image/webp')
.send(await service.storage.preview(accessSubject(req, id(req)), id(req))),
);
app.post('/api/access/delete-batch', async (req) => {
const body = z
.object({ ids: z.array(z.uuid()).min(1).max(100) })
.strict()
.parse(req.body),
g = accessSubject(req, body.ids[0]!);
const result = service.storage.deleteAccess(g, body.ids);
if (g.kind === 'user') service.accounts.notify(g.id);
else presence.emit(g.id, 'access:updated', {});
return result;
});
app.post('/api/deletion-operations/:id/undo', async (req) => {
const operation = db
.select()
.from(tables.deletions)
.where(eq(tables.deletions.id, id(req)))
.get();
const user = service.auth.session(req.cookies.hop_account)?.user;
const g: StorageSubject =
user && operation?.userId === user.id ? { id: user.id, kind: 'user' } : auth(req);
const result = service.storage.undo(g, id(req));
if (g.kind === 'user') service.accounts.notify(g.id);
else presence.emit(g.id, 'access:updated', {});
return result;
});
const credentials = z
.object({ email: z.string().max(254), password: z.string().min(1).max(1024) })
.strict();
const registration = credentials
.extend({
passwordConfirmation: z.string().max(1024),
displayName: z
.string()
.trim()
.min(1)
.max(40)
.refine((v) => !/[\x00-\x1f\x7f<>]/.test(v)),
setupToken: z.string().min(32).max(256).optional(),
})
.refine((v) => v.password === v.passwordConfirmation);
const tokenInput = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict();
const clearAccount = (reply: any) =>
reply.clearCookie('hop_account', {
path: '/',
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
});
app.get('/api/auth/me', async (req) => {
const current = service.auth.session(req.cookies.hop_account);
return {
account: current ? service.auth.serialize(current.user) : null,
registrationOpen: cfg.registrationMode === 'open',
passwordMinLength: cfg.production ? 12 : 1,
};
});
app.post('/api/auth/register', async (req) => {
const input = registration.parse(req.body);
if (input.setupToken) fail(400, 'INVALID_REQUEST', '초기화는 별도 경로를 사용해 주세요.');
return service.auth.register(
input.email,
input.password,
input.displayName,
ip(req) ?? 'unknown',
);
});
app.post('/api/auth/setup', async (req) => {
const input = registration.parse(req.body);
if (!input.setupToken) fail(403, 'SETUP_DISABLED', '초기화 토큰이 필요합니다.');
return service.auth.register(
input.email,
input.password,
input.displayName,
ip(req) ?? 'unknown',
input.setupToken,
);
});
app.post('/api/auth/verify-email', async (req) =>
service.auth.verify(tokenInput.parse(req.body).token),
);
app.post('/api/auth/verify-email-change', async (req) =>
service.auth.verifyEmailChange(tokenInput.parse(req.body).token),
);
app.post('/api/auth/resend-verification', async (req) =>
service.auth.resend(
z
.object({ email: z.string().max(254) })
.strict()
.parse(req.body).email,
'verify',
ip(req) ?? 'unknown',
),
);
app.post('/api/auth/login', async (req, reply) => {
const input = credentials.parse(req.body);
const result = await service.auth.login(
input.email,
input.password,
ip(req) ?? 'unknown',
req.cookies.hop_account,
);
reply.setCookie('hop_account', result.token, {
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
path: '/',
expires: new Date(result.expiresAt),
});
return { account: result.account };
});
app.post('/api/auth/logout', async (req, reply) => {
service.auth.logout(req.cookies.hop_account);
clearAccount(reply);
return { ok: true };
});
app.post('/api/auth/forgot-password', async (req) =>
service.auth.resend(
z
.object({ email: z.string().max(254) })
.strict()
.parse(req.body).email,
'reset',
ip(req) ?? 'unknown',
),
);
app.post('/api/auth/reset-password', async (req) => {
const input = tokenInput
.extend({ password: z.string().max(1024), passwordConfirmation: z.string().max(1024) })
.refine((v) => v.password === v.passwordConfirmation)
.parse(req.body);
return service.auth.resetPassword(input.token, input.password);
});
app.post('/api/auth/change-password', async (req, reply) => {
const input = z
.object({
currentPassword: z.string().max(1024),
password: z.string().max(1024),
passwordConfirmation: z.string().max(1024),
})
.strict()
.refine((v) => v.password === v.passwordConfirmation)
.parse(req.body);
await service.auth.changePassword(account(req).user, input.currentPassword, input.password);
clearAccount(reply);
return { ok: true };
});
app.post('/api/auth/change-email', async (req) => {
const input = z
.object({ email: z.string().max(254), password: z.string().max(1024) })
.strict()
.parse(req.body);
await service.auth.changeEmail(account(req).user, input.email, input.password);
return { ok: true };
});
for (const view of ['inbox', 'vault', 'sent', 'favorites'] as const)
app.get(`/api/${view}`, async (req) => {
const input = z
.object({
cursor: z.string().max(512).optional(),
limit: z.coerce.number().int().min(1).max(100).default(30),
search: z.string().max(256).optional(),
kind: z.enum(['url', 'file']).optional(),
read: z.enum(['true', 'false']).optional(),
})
.parse(req.query);
return service.accounts.list(account(req, true).user.id, view, {
...input,
read: input.read === undefined ? undefined : input.read === 'true',
});
});
app.get('/api/vault/contents', async (req) => {
const input = z
.object({
cursor: z.string().max(512).optional(),
limit: z.coerce.number().int().min(1).max(100).default(30),
})
.parse(req.query);
return service.accounts.list(account(req, true).user.id, 'contents', input);
});
const noteInput = z
.string()
.trim()
.max(200)
.refine((value) => !/[\r\n]/.test(value))
.default('');
app.post('/api/vault', async (req) => {
const user = account(req, true).user;
const input = z
.discriminatedUnion('kind', [
z
.object({
kind: z.literal('url'),
urls: z.array(z.string().max(cfg.maxUrlBytes)).min(1).max(cfg.maxItems),
note: noteInput,
})
.strict(),
z
.object({
kind: z.literal('file'),
files: requestInput.shape.files.unwrap().min(1).max(cfg.maxItems),
note: noteInput,
})
.strict(),
])
.parse(req.body);
const result = service.accounts.idem(user.id, 'vault-create', key(req), input, () => {
const created = service.accounts.create(user.id, {
email: user.email!,
kind: input.kind,
count: input.kind === 'url' ? input.urls.length : input.files.length,
byteSize:
input.kind === 'url'
? Buffer.byteLength(input.urls.join('\n'))
: input.files.reduce((sum, file) => sum + file.size, 0),
...(input.kind === 'file' ? { files: input.files } : {}),
});
db.update(tables.requests)
.set({ note: input.note || null })
.where(eq(tables.requests.id, created.requestId))
.run();
if (input.kind === 'url')
service.accounts.complete(user.id, created.requestId, { urls: input.urls });
return created;
});
service.notifyTransfer(result.requestId);
return result;
});
app.patch('/api/vault/:id/note', async (req) => {
const input = z.object({ note: noteInput }).strict().parse(req.body);
service.vault.note(account(req, true).user.id, id(req), input.note);
return { ok: true };
});
app.post('/api/vault/:id/send', async (req) => {
const guest = auth(req),
current = account(req, true),
accessId = id(req);
const input = z
.object({ targetDeviceId: requestInput.shape.targetDeviceId })
.strict()
.parse(req.body);
const result = service.accounts.idem(
current.user.id,
`vault-send:${accessId}`,
key(req),
{ ...input, guestId: guest.id, sessionId: current.session.id },
() =>
service.vault.forward(guest, current, accessId, input.targetDeviceId, ip(req) ?? 'unknown'),
);
service.notifyTransfer(result.requestId);
return result;
});
app.patch('/api/access/:id/favorite', async (req) => {
account(req, true);
fail(410, 'PERMANENT_RETENTION_REMOVED', '모든 자료는 최대 7일간 보관됩니다.');
});
app.post('/api/access/:id/save-to-account', async (req) => {
account(req, true);
fail(410, 'GUEST_IMPORT_REMOVED', '로그인 중 받은 자료만 자동 기록합니다.');
});
app.get('/api/account/preferences', async (req) => ({
receivePolicy: account(req, true).user.receivePolicy,
}));
app.patch('/api/account/preferences', async (req) => {
const input = z
.object({ receivePolicy: z.enum(['requests', 'allowed', 'none']) })
.strict()
.parse(req.body),
user = account(req, true).user;
db.update(tables.users).set(input).where(eq(tables.users.id, user.id)).run();
service.accounts.notify(user.id);
return { ok: true };
});
app.get('/api/account/rules', async (req) => ({
rules: db
.select({
id: tables.rules.targetUserId,
action: tables.rules.action,
email: tables.users.email,
displayName: tables.users.displayName,
})
.from(tables.rules)
.innerJoin(tables.users, eq(tables.rules.targetUserId, tables.users.id))
.where(eq(tables.rules.userId, account(req, true).user.id))
.all(),
}));
app.put('/api/account/rules/:targetUserId', async (req) => {
const target = z.object({ targetUserId: z.uuid() }).parse(req.params).targetUserId,
input = z
.object({ action: z.enum(['allow', 'block']) })
.strict()
.parse(req.body);
service.accounts.rule(account(req, true).user.id, target, input.action);
return { ok: true };
});
app.delete('/api/account/rules/:targetUserId', async (req) => {
const target = z.object({ targetUserId: z.uuid() }).parse(req.params).targetUserId,
user = account(req, true).user;
db.delete(tables.rules)
.where(and(eq(tables.rules.userId, user.id), eq(tables.rules.targetUserId, target)))
.run();
service.accounts.notify(user.id);
return { ok: true };
});
app.get('/api/account/storage', async (req) => ({
usedBytes: service.accounts.used(account(req, true).user.id),
limitBytes: cfg.userQuota,
}));
const webRoot = cfg.webRoot;
if (webRoot && existsSync(webRoot)) {
await app.register(serveStatic, { root: webRoot, index: 'index.html' });
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api/') || req.url.startsWith('/socket.io/'))
return reply
.status(404)
.send({ code: 'NOT_FOUND', message: '경로를 찾을 수 없습니다.', requestId: req.id });
return reply.sendFile('index.html');
});
}
let storageCleanup: Promise<void> | undefined;
const cleanup = setInterval(() => {
service.cleanup();
service.auth.enforceSessions();
storageCleanup = service.storage
.cleanup()
.catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed'));
}, 1000);
cleanup.unref();
app.addHook('preClose', async () => {
service.storage.close();
io.disconnectSockets(true);
});
app.addHook('onClose', async () => {
clearInterval(cleanup);
presence.close();
service.storage.close();
await storageCleanup;
await new Promise<void>((resolve) => io.close(() => resolve()));
sqlite.close();
});
return {
app,
io,
service,
sqlite,
setDevOrigin: (value: string) => {
devOrigin = value;
},
};
}

374
apps/server/src/auth.ts Normal file
View File

@@ -0,0 +1,374 @@
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { and, eq, gt, isNull, inArray } from 'drizzle-orm';
import argon2 from 'argon2';
import type { Service } from './service.js';
import type { Account } from '@hop/shared';
import * as s from './db/schema.js';
import { fail, hash, randomToken } from './security.js';
export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string };
export type Mailer = (mail: Mail) => Promise<void>;
export type User = typeof s.users.$inferSelect;
const passwordOptions = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1 };
let dummy: Promise<string> | undefined;
export function normalizeEmail(value: string) {
const trimmed = value.trim();
const split = trimmed.lastIndexOf('@');
if (
split < 1 ||
split === trimmed.length - 1 ||
trimmed.length > 254 ||
/[\s\x00-\x1f\x7f]/.test(trimmed)
)
fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.');
const local = trimmed.slice(0, split),
domain = trimmed.slice(split + 1).toLowerCase();
if (
!/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local) ||
!/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)
)
fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.');
return `${local}@${domain}`;
}
export class Auth {
readonly sendMail: Mailer;
readonly mailReady: boolean;
constructor(
readonly service: Service,
mailer?: Mailer,
) {
this.mailReady = Boolean(mailer);
this.sendMail =
mailer ??
(async () => fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.'));
if (service.cfg.registrationMode === 'open' && !this.mailReady)
throw Error('Mail provider must be configured before opening registration');
}
serialize(user: User): Account {
return {
id: user.id,
email: user.email ?? '',
displayName: user.status === 'deleted' ? '탈퇴한 사용자' : user.displayName,
role: user.role,
status: user.status,
verified: Boolean(user.verifiedAt && user.status === 'active'),
};
}
user(id: string) {
const user = this.service.db.select().from(s.users).where(eq(s.users.id, id)).get();
if (!user) fail(404, 'ACCOUNT_NOT_FOUND', '계정을 찾을 수 없습니다.');
return user!;
}
session(token: string | undefined) {
if (!token) return undefined;
const row = this.service.db
.select({ session: s.accountSessions, user: s.users })
.from(s.accountSessions)
.innerJoin(s.users, eq(s.accountSessions.userId, s.users.id))
.where(
and(
eq(s.accountSessions.tokenHash, hash(token)),
isNull(s.accountSessions.revokedAt),
gt(s.accountSessions.expiresAt, Date.now()),
inArray(s.users.status, ['pending', 'active']),
),
)
.get();
return row;
}
require(token: string | undefined, verified = false) {
const current = this.session(token);
if (!current) fail(401, 'LOGIN_REQUIRED', '로그인이 필요합니다.');
if (verified && !this.serialize(current!.user).verified)
fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.');
return current!;
}
async password(value: string) {
const minimum = this.service.cfg.production ? 12 : 1;
if (Array.from(value).length < minimum || value.length > 1024)
fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`);
return argon2.hash(value, passwordOptions);
}
async token(user: User, purpose: 'verify' | 'reset' | 'email-change', value?: string) {
if (!this.mailReady)
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
const token = randomToken(),
now = Date.now();
this.service.db.transaction(() => {
this.service.db
.update(s.accountTokens)
.set({ usedAt: now })
.where(
and(
eq(s.accountTokens.userId, user.id),
eq(s.accountTokens.purpose, purpose),
isNull(s.accountTokens.usedAt),
),
)
.run();
this.service.db
.insert(s.accountTokens)
.values({
id: randomUUID(),
userId: user.id,
purpose,
tokenHash: hash(token),
value,
createdAt: now,
expiresAt: now + (purpose === 'verify' ? 24 : 1) * 3600000,
})
.run();
});
const action =
purpose === 'verify'
? 'verify'
: purpose === 'reset'
? 'reset-password'
: 'verify-email-change';
await this.sendMail({
idempotencyKey: `auth-${purpose}-${hash(token)}`,
to: value ?? user.email!,
subject: purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
text: `${purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면'} 다음 주소를 열어 주세요.\n${this.service.cfg.appUrl}/#${action}=${token}\n이 링크는 한 번만 사용할 수 있습니다.`,
});
}
async register(
email: string,
password: string,
displayName: string,
ip: string,
setupToken?: string,
) {
const { db, cfg, limiter } = this.service;
limiter.hit(`register-ip:${ip}`, 5, 3600000);
let admin = false;
if (setupToken) {
if (
!cfg.setupToken ||
hash(setupToken) !== hash(cfg.setupToken) ||
db.select().from(s.users).limit(1).get()
)
fail(403, 'SETUP_DISABLED', '초기화를 진행할 수 없습니다.');
admin = true;
} else if (cfg.registrationMode !== 'open')
fail(403, 'REGISTRATION_CLOSED', '현재 공개 가입이 닫혀 있습니다.');
if (!this.mailReady)
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
const normalized = normalizeEmail(email),
passwordHash = await this.password(password);
if (db.select().from(s.users).where(eq(s.users.email, normalized)).get())
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
const user = db.transaction(() => {
if (admin && db.select().from(s.users).limit(1).get())
fail(409, 'SETUP_DISABLED', '초기화가 이미 완료되었습니다.');
const id = randomUUID();
db.insert(s.users)
.values({
id,
email: normalized,
passwordHash,
displayName,
role: admin ? 'admin' : 'user',
status: 'pending',
createdAt: Date.now(),
})
.run();
return this.user(id);
});
await this.token(user, 'verify');
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
}
consume(
token: string,
purpose: 'verify' | 'reset' | 'email-change',
fn: (token: typeof s.accountTokens.$inferSelect, user: User) => void,
) {
return this.service.db.transaction(() => {
const row = this.service.db
.select()
.from(s.accountTokens)
.where(
and(
eq(s.accountTokens.tokenHash, hash(token)),
eq(s.accountTokens.purpose, purpose),
isNull(s.accountTokens.usedAt),
gt(s.accountTokens.expiresAt, Date.now()),
),
)
.get();
if (!row) fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.');
const user = this.user(row!.userId);
if (['suspended', 'deleted'].includes(user.status))
fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.');
fn(row!, user);
this.service.db
.update(s.accountTokens)
.set({ usedAt: Date.now() })
.where(eq(s.accountTokens.id, row!.id))
.run();
return { ok: true };
});
}
verify(token: string) {
return this.consume(token, 'verify', (_t, user) => {
this.service.db
.update(s.users)
.set({ status: 'active', verifiedAt: Date.now() })
.where(eq(s.users.id, user.id))
.run();
});
}
async login(email: string, password: string, ip: string, oldToken?: string) {
const normalized = normalizeEmail(email);
this.service.limiter.hit(`login-ip:${ip}`, 20);
this.service.limiter.hit(`login-email:${hash(normalized)}`, 10);
const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get();
dummy ??= argon2.hash(randomToken(), passwordOptions);
const valid = await argon2
.verify(user?.passwordHash ?? (await dummy), password)
.catch(() => false);
if (!valid || !user || !['pending', 'active'].includes(user.status))
fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.');
const token = randomToken(),
now = Date.now();
this.service.db.transaction(() => {
if (oldToken)
this.service.db
.update(s.accountSessions)
.set({ revokedAt: now })
.where(eq(s.accountSessions.tokenHash, hash(oldToken)))
.run();
this.service.db
.insert(s.accountSessions)
.values({
id: randomUUID(),
userId: user!.id,
tokenHash: hash(token),
createdAt: now,
lastSeen: now,
expiresAt: now + 30 * 86400000,
})
.run();
});
return { token, account: this.serialize(user!), expiresAt: now + 30 * 86400000 };
}
logout(token: string | undefined) {
const current = this.session(token);
if (!current) return;
this.service.storage.abortSession(current.session.id);
this.service.db
.update(s.accountSessions)
.set({ revokedAt: Date.now() })
.where(eq(s.accountSessions.id, current.session.id))
.run();
this.service.storage.abortSession(current.session.id);
this.service.io?.to(`account-session:${current.session.id}`).emit('account:revoked', {});
this.service.io
?.in(`account-session:${current.session.id}`)
.socketsLeave(`account:${current.user.id}`);
}
revoke(userId: string) {
this.service.storage.abortUser(userId);
this.service.storage.abortUser(userId);
this.service.db
.update(s.accountSessions)
.set({ revokedAt: Date.now() })
.where(eq(s.accountSessions.userId, userId))
.run();
this.service.io?.to(`account:${userId}`).emit('account:revoked', {});
this.service.io?.in(`account:${userId}`).socketsLeave(`account:${userId}`);
}
enforceSessions() {
for (const socket of this.service.io?.sockets.sockets.values() ?? []) {
const id = socket.data.accountSessionId;
if (!id) continue;
const session = this.service.db
.select({ session: s.accountSessions, user: s.users })
.from(s.accountSessions)
.innerJoin(s.users, eq(s.accountSessions.userId, s.users.id))
.where(eq(s.accountSessions.id, id))
.get();
if (
!session ||
session.session.revokedAt ||
session.session.expiresAt <= Date.now() ||
!['pending', 'active'].includes(session.user.status)
) {
socket.leave(`account:${socket.data.accountId}`);
socket.leave(`account-session:${id}`);
socket.data.accountId = undefined;
socket.data.accountSessionId = undefined;
socket.emit('account:revoked', {});
}
}
}
async resend(email: string, purpose: 'verify' | 'reset', ip: string) {
const normalized = normalizeEmail(email);
this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000);
this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000);
const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get();
if (
user &&
((purpose === 'verify' && user.status === 'pending') ||
(purpose === 'reset' && ['active', 'pending'].includes(user.status)))
)
await this.token(user, purpose);
return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' };
}
async resetPassword(token: string, password: string) {
const passwordHash = await this.password(password);
let userId = '';
const result = this.consume(token, 'reset', (_t, user) => {
userId = user.id;
this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run();
this.service.db
.update(s.accountTokens)
.set({ usedAt: Date.now() })
.where(eq(s.accountTokens.userId, user.id))
.run();
});
this.revoke(userId);
return result;
}
async changePassword(user: User, currentPassword: string, password: string) {
if (!user.passwordHash || !(await argon2.verify(user.passwordHash, currentPassword)))
fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.');
const passwordHash = await this.password(password);
this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run();
this.revoke(user.id);
}
async changeEmail(user: User, email: string, password: string) {
if (!user.passwordHash || !(await argon2.verify(user.passwordHash, password)))
fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.');
const normalized = normalizeEmail(email);
if (
normalized === user.email ||
this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get()
)
fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.');
await this.token(user, 'email-change', normalized);
}
async verifyEmailChange(token: string) {
let previous = '',
next = '';
const result = this.consume(token, 'email-change', (t, user) => {
if (
!t.value ||
this.service.db.select().from(s.users).where(eq(s.users.email, t.value)).get()
)
fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.');
previous = user.email!;
next = t.value!;
this.service.db
.update(s.users)
.set({ email: t.value, verifiedAt: Date.now(), status: 'active' })
.where(eq(s.users.id, user.id))
.run();
});
await this.sendMail({
to: previous,
subject: 'hop. 이메일 주소 변경 안내',
text: `계정 이메일이 ${next}로 변경되었습니다.`,
});
return result;
}
}

106
apps/server/src/config.ts Normal file
View File

@@ -0,0 +1,106 @@
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
export type Config = {
production: boolean;
appUrl: string;
databasePath: string;
sessionSecret: string;
discoverySecret: string;
discoveryEnabled: boolean;
trustedProxies: string[];
port: number;
host: string;
guestDays: number;
requestSeconds: number;
uploadStartHours: number;
spaceMinutes: number;
spaceMax: number;
offlineGraceMs: number;
maxUrlBytes: number;
maxItems: number;
urlRetentionDays: number;
sessionRate: number;
ipRate: number;
pairRate: number;
rejectCooldownMs: number;
uploadDir: string;
maxFileBytes: number;
guestQuota: number;
totalGuestQuota: number;
minFreeBytes: number;
fileRetentionDays: number;
orphanHours: number;
undoSeconds: number;
registrationMode: 'open' | 'closed';
setupToken?: string;
mailFrom: string;
resendApiKey?: string;
userQuota: number;
emailRequestDays: number;
webRoot?: string;
};
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const production = env.NODE_ENV === 'production';
const num = (key: string, fallback: number, min = 1) => {
const n = Number(env[key] ?? fallback);
if (!Number.isSafeInteger(n) || n < min) throw Error(`Invalid ${key}`);
return n;
};
const secret = (key: string) => {
const v = env[key];
if (production && (!v || v.length < 32 || v.startsWith('replace-')))
throw Error(`${key} must be a random secret (32+ characters)`);
return v && v.length >= 32 ? v : randomBytes(32).toString('base64url');
};
const port = num('PORT', 0, 0);
if (production && port === 0) throw Error('Set PORT explicitly before deployment');
const appUrl = env.APP_URL ?? 'http://127.0.0.1';
if (production && !appUrl.startsWith('https://'))
throw Error('Production APP_URL must use HTTPS');
return {
production,
appUrl,
databasePath:
env.DATABASE_PATH ?? fileURLToPath(new URL('../../../.data/hop.sqlite', import.meta.url)),
sessionSecret: secret('SESSION_SECRET'),
discoverySecret: secret('DISCOVERY_SECRET'),
discoveryEnabled:
env.SAME_NETWORK_DISCOVERY_ENABLED === undefined
? !production
: env.SAME_NETWORK_DISCOVERY_ENABLED === 'true',
trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '')
.split(',')
.map((v) => v.trim())
.filter(Boolean),
port,
host: env.HOST ?? '127.0.0.1',
guestDays: num('GUEST_SESSION_RETENTION_DAYS', 30),
requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60),
uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24),
spaceMinutes: num('SPACE_RETENTION_MINUTES', 10),
spaceMax: num('SPACE_MAX_DEVICES', 10),
offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0),
maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024,
maxItems: num('MAX_ITEMS_PER_TRANSFER', 20),
urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)),
sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5),
ipRate: num('IP_REQUESTS_PER_MINUTE', 20),
pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2),
rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000,
uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)),
maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576,
guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576,
totalGuestQuota: num('TOTAL_GUEST_STORAGE_LIMIT_MB', 20480) * 1048576,
minFreeBytes: num('MIN_FREE_DISK_MB', 5120, 0) * 1048576,
fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)),
orphanHours: num('ORPHAN_UPLOAD_RETENTION_HOURS', 24),
undoSeconds: num('DELETE_UNDO_SECONDS', 5),
registrationMode: env.REGISTRATION_MODE === 'open' ? 'open' : 'closed',
setupToken: env.SETUP_TOKEN,
resendApiKey: env.RESEND_API_KEY?.trim() || undefined,
mailFrom: env.MAIL_FROM ?? 'hop. <hello@noname.company>',
userQuota: num('USER_STORAGE_LIMIT_MB', 10240) * 1048576,
emailRequestDays: num('EMAIL_REQUEST_RETENTION_DAYS', 7),
webRoot: env.WEB_ROOT,
};
}

View File

@@ -0,0 +1,57 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { sql } from 'drizzle-orm';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from './schema.js';
export function openDatabase(path: string) {
if (path !== ':memory:') mkdirSync(dirname(resolve(path)), { recursive: true });
const sqlite = new Database(path);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('busy_timeout = 5000');
const db = drizzle(sqlite, { schema });
// SQLite table rebuilds require foreign_keys OFF outside the transaction. Check all references
// before committing, then restore enforcement before accepting any application request.
sqlite.pragma('foreign_keys = OFF');
try {
sqlite.exec(
'CREATE TABLE IF NOT EXISTS __drizzle_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)',
);
const migrations = readMigrationFiles({
migrationsFolder: fileURLToPath(new URL('../../drizzle', import.meta.url)),
});
db.transaction((tx) => {
const latest = sqlite
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1')
.get() as { created_at: number } | undefined;
for (const migration of migrations) {
if (latest && migration.folderMillis <= latest.created_at) continue;
// A development server applied the first account schema before its deletion-column
// correction was generated. Preserve that ledger/data; migration 0005 applies the sole
// remaining structural difference. Accept only this exact observed legacy revision.
const legacy = sqlite
.prepare('SELECT hash FROM __drizzle_migrations WHERE created_at = ?')
.get(1791448938074) as { hash: string } | undefined;
const legacyAccountRevision =
migration === migrations[4] &&
legacy?.hash === 'fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068';
if (!legacyAccountRevision)
for (const statement of migration.sql) tx.run(sql.raw(statement));
sqlite
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
if ((sqlite.pragma('foreign_key_check') as unknown[]).length)
throw Error('Migration foreign-key check failed');
});
} catch (error) {
sqlite.close();
throw error;
} finally {
if (sqlite.open) sqlite.pragma('foreign_keys = ON');
}
return { db, sqlite };
}
export type DB = ReturnType<typeof openDatabase>['db'];

View File

@@ -0,0 +1,354 @@
import { sqliteTable, text, integer, index, uniqueIndex, check } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text().primaryKey(),
email: text().unique(),
displayName: text().notNull(),
passwordHash: text(),
role: text({ enum: ['user', 'admin'] })
.notNull()
.default('user'),
status: text({ enum: ['pending', 'active', 'suspended', 'deleted'] }).notNull(),
verifiedAt: integer(),
createdAt: integer().notNull(),
receivePolicy: text({ enum: ['requests', 'allowed', 'none'] })
.notNull()
.default('requests'),
});
export const accountSessions = sqliteTable(
'account_sessions',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
tokenHash: text().notNull().unique(),
createdAt: integer().notNull(),
lastSeen: integer().notNull(),
expiresAt: integer().notNull(),
revokedAt: integer(),
},
(t) => [index('account_session_user').on(t.userId, t.expiresAt)],
);
export const accountTokens = sqliteTable(
'account_tokens',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
purpose: text({ enum: ['verify', 'reset', 'email-change'] }).notNull(),
tokenHash: text().notNull().unique(),
value: text(),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
usedAt: integer(),
},
(t) => [index('account_token_expiry').on(t.expiresAt)],
);
export const rules = sqliteTable(
'user_rules',
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => users.id),
targetUserId: text()
.notNull()
.references(() => users.id),
action: text({ enum: ['allow', 'block'] }).notNull(),
},
(t) => [
uniqueIndex('user_rule_pair').on(t.userId, t.targetUserId),
check('rule_not_self', sql`${t.userId} <> ${t.targetUserId}`),
],
);
export const guests = sqliteTable(
'guest_sessions',
{
id: text().primaryKey(),
tokenHash: text().notNull().unique(),
expiresAt: integer().notNull(),
revokedAt: integer(),
createdAt: integer().notNull(),
},
(t) => [index('guest_expiry').on(t.expiresAt)],
);
export const devices = sqliteTable(
'browser_devices',
{
id: text().primaryKey(),
guestId: text()
.notNull()
.unique()
.references(() => guests.id),
name: text().notNull(),
code: text().notNull().unique(),
discoverable: integer({ mode: 'boolean' }).notNull().default(true),
lastSeen: integer().notNull(),
},
(t) => [check('device_name_length', sql`length(${t.name}) between 1 and 40`)],
);
export const spaces = sqliteTable(
'connection_spaces',
{
id: text().primaryKey(),
creatorGuestId: text()
.notNull()
.references(() => guests.id),
tokenHash: text().notNull().unique(),
codeHash: text().notNull().unique(),
approvalRequired: integer({ mode: 'boolean' }).notNull(),
expiresAt: integer().notNull(),
closedAt: integer(),
createdAt: integer().notNull(),
},
(t) => [index('space_expiry').on(t.expiresAt)],
);
export const members = sqliteTable(
'space_members',
{
id: text().primaryKey(),
spaceId: text()
.notNull()
.references(() => spaces.id),
deviceId: text()
.notNull()
.references(() => devices.id),
status: text({ enum: ['pending', 'approved', 'removed'] }).notNull(),
joinedAt: integer().notNull(),
},
(t) => [
uniqueIndex('space_device').on(t.spaceId, t.deviceId),
uniqueIndex('member_single_active_device')
.on(t.deviceId)
.where(sql`${t.status} IN ('pending', 'approved')`),
],
);
export const blocks = sqliteTable(
'guest_blocks',
{
id: text().primaryKey(),
guestId: text()
.notNull()
.references(() => guests.id),
targetGuestId: text()
.notNull()
.references(() => guests.id),
},
(t) => [uniqueIndex('guest_block_pair').on(t.guestId, t.targetGuestId)],
);
export const requests = sqliteTable(
'transfer_requests',
{
id: text().primaryKey(),
mode: text({ enum: ['local', 'email'] })
.notNull()
.default('local'),
senderGuestId: text().references(() => guests.id),
recipientGuestId: text().references(() => guests.id),
senderUserId: text().references(() => users.id),
recipientUserId: text().references(() => users.id),
kind: text({ enum: ['text', 'url', 'file'] }).notNull(),
byteSize: integer().notNull(),
manifest: text(),
note: text(),
count: integer().notNull(),
status: text({ enum: ['pending', 'accepted', 'rejected', 'expired', 'cancelled'] }).notNull(),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
acceptedAt: integer(),
rejectedAt: integer(),
startBy: integer(),
},
(t) => [
index('requests_sender_time').on(t.senderGuestId, t.createdAt),
index('requests_recipient_time').on(t.recipientGuestId, t.createdAt),
index('requests_expiry').on(t.status, t.expiresAt),
check(
'request_different_guests',
sql`${t.mode} <> 'local' OR ${t.senderGuestId} <> ${t.recipientGuestId}`,
),
check(
'request_sender_subject',
sql`(${t.senderGuestId} IS NOT NULL) + (${t.senderUserId} IS NOT NULL) = 1`,
),
check(
'request_recipient_subject',
sql`(${t.recipientGuestId} IS NOT NULL) + (${t.recipientUserId} IS NOT NULL) = 1`,
),
check(
'request_mode_subjects',
sql`(${t.mode} = 'local' AND ${t.senderGuestId} IS NOT NULL AND ${t.recipientGuestId} IS NOT NULL) OR (${t.mode} = 'email' AND ${t.senderUserId} IS NOT NULL AND ${t.recipientUserId} IS NOT NULL)`,
),
index('requests_sender_user').on(t.senderUserId, t.createdAt),
index('requests_recipient_user').on(t.recipientUserId, t.createdAt),
],
);
export const batches = sqliteTable('transfer_batches', {
id: text().primaryKey(),
requestId: text()
.notNull()
.unique()
.references(() => requests.id),
status: text({ enum: ['waiting', 'uploading', 'stored', 'failed'] }).notNull(),
storedAt: integer(),
});
export const files = sqliteTable(
'files',
{
id: text().primaryKey(),
requestId: text()
.notNull()
.references(() => requests.id),
ownerGuestId: text().references(() => guests.id),
ownerUserId: text().references(() => users.id),
position: integer().notNull(),
storageKey: text().notNull().unique(),
name: text().notNull(),
type: text().notNull(),
size: integer().notNull(),
checksum: text(),
status: text({ enum: ['pending', 'uploading', 'stored', 'failed', 'cleaning'] }).notNull(),
uploadId: text(),
uploadKey: text(),
updatedAt: integer().notNull(),
},
(t) => [
uniqueIndex('request_file_position').on(t.requestId, t.position),
index('files_owner_state').on(t.ownerGuestId, t.status),
check('file_nonnegative_size', sql`${t.size} >= 0`),
check(
'file_owner_subject',
sql`(${t.ownerGuestId} IS NOT NULL) + (${t.ownerUserId} IS NOT NULL) = 1`,
),
],
);
export const reservations = sqliteTable(
'quota_reservations',
{
id: text().primaryKey(),
fileId: text()
.notNull()
.unique()
.references(() => files.id),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
bytes: integer().notNull(),
expiresAt: integer().notNull(),
},
(t) => [
check('reservations_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
index('quota_guest_expiry').on(t.guestId, t.expiresAt),
check('reservation_nonnegative', sql`${t.bytes} >= 0`),
],
);
export const deletions = sqliteTable(
'deletion_operations',
{
id: text().primaryKey(),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
undoUntil: integer().notNull(),
createdAt: integer().notNull(),
restoredAt: integer(),
},
(t) => [
check('deletion_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);
export const deletionTargets = sqliteTable('deletion_targets', {
id: text().primaryKey(),
operationId: text()
.notNull()
.references(() => deletions.id),
accessId: text().notNull(),
});
export const items = sqliteTable(
'items',
{
id: text().primaryKey(),
batchId: text()
.notNull()
.references(() => batches.id),
kind: text({ enum: ['text', 'url', 'file'] }).notNull(),
text: text(),
url: text(),
fileId: text().references(() => files.id),
createdAt: integer().notNull(),
expiresAt: integer().notNull(),
},
(t) => [
index('items_expiry').on(t.expiresAt),
check(
'item_payload',
sql`(${t.kind} = 'text' AND ${t.text} IS NOT NULL AND ${t.url} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'url' AND ${t.url} IS NOT NULL AND ${t.text} IS NULL AND ${t.fileId} IS NULL) OR (${t.kind} = 'file' AND ${t.fileId} IS NOT NULL AND ${t.text} IS NULL AND ${t.url} IS NULL)`,
),
],
);
export const access = sqliteTable(
'item_access',
{
id: text().primaryKey(),
itemId: text()
.notNull()
.references(() => items.id),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
role: text({ enum: ['sender', 'recipient', 'self'] }).notNull(),
read: integer({ mode: 'boolean' }).notNull().default(false),
deletedAt: integer(),
favorite: integer({ mode: 'boolean' }).notNull().default(false),
note: text(),
},
(t) => [
check('access_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
uniqueIndex('access_subject_role').on(t.itemId, t.guestId, t.role),
uniqueIndex('access_account_role').on(t.itemId, t.userId, t.role),
index('access_account').on(t.userId, t.deletedAt),
index('access_guest').on(t.guestId, t.deletedAt),
],
);
export const receiptAccounts = sqliteTable('request_receipt_accounts', {
requestId: text()
.primaryKey()
.references(() => requests.id),
userId: text()
.notNull()
.references(() => users.id),
sessionId: text()
.notNull()
.references(() => accountSessions.id),
});
export const vaultTransfers = sqliteTable('vault_transfers', {
requestId: text()
.primaryKey()
.references(() => requests.id),
accessId: text()
.notNull()
.references(() => access.id),
userId: text()
.notNull()
.references(() => users.id),
sessionId: text()
.notNull()
.references(() => accountSessions.id),
});
export const idempotency = sqliteTable(
'idempotency_records',
{
id: text().primaryKey(),
userId: text().references(() => users.id),
guestId: text().references(() => guests.id),
operation: text().notNull(),
key: text().notNull(),
payloadHash: text().notNull(),
response: text().notNull(),
createdAt: integer().notNull(),
},
(t) => [
uniqueIndex('idempotency_scope').on(t.guestId, t.operation, t.key),
uniqueIndex('account_idempotency_scope').on(t.userId, t.operation, t.key),
check('idempotency_subject', sql`(${t.guestId} IS NOT NULL) + (${t.userId} IS NOT NULL) = 1`),
],
);

48
apps/server/src/mail.ts Normal file
View File

@@ -0,0 +1,48 @@
import { randomUUID } from 'node:crypto';
import type { Config } from './config.js';
import type { Mailer } from './auth.js';
import { ApiError } from './security.js';
/** The API key stays in the server process. Delivery receipts and provider error bodies
* never become public API responses or application logs. */
export function createResendMailer(
cfg: Pick<Config, 'resendApiKey' | 'mailFrom'>,
fetcher: typeof fetch = fetch,
): Mailer | undefined {
if (!cfg.resendApiKey) return undefined;
return async (mail) => {
try {
const response = await fetcher('https://api.resend.com/emails', {
method: 'POST',
redirect: 'error',
headers: {
Authorization: `Bearer ${cfg.resendApiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': mail.idempotencyKey ?? randomUUID(),
},
body: JSON.stringify({
from: cfg.mailFrom,
to: [mail.to],
subject: mail.subject,
text: mail.text,
}),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw new Error('Delivery rejected');
const receipt: unknown = await response.json();
if (
!receipt ||
typeof receipt !== 'object' ||
!('id' in receipt) ||
typeof receipt.id !== 'string'
)
throw new Error('Invalid receipt');
} catch {
throw new ApiError(
503,
'MAIL_DELIVERY_FAILED',
'메일을 보내지 못했습니다. 잠시 후 다시 시도해 주세요.',
);
}
};
}

13
apps/server/src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { loadConfig } from './config.js';
import { createApp } from './app.js';
const cfg = loadConfig();
const runtime = await createApp(cfg);
await runtime.app.listen({ port: cfg.port, host: cfg.host });
const address = runtime.app.server.address();
if (!address || typeof address === 'string') throw Error('No server address');
if (!cfg.production) runtime.setDevOrigin(`http://${cfg.host}:${address.port}`);
process.stdout.write(`hop. ready at http://${cfg.host}:${address.port}\n`);
for (const signal of ['SIGTERM', 'SIGINT'] as const)
process.on(signal, () => {
void runtime.app.close();
});

View File

@@ -0,0 +1,77 @@
import type { Server, Socket } from 'socket.io';
import type { Config } from './config.js';
import { hmac, publicIp } from './security.js';
export class Presence {
private connections = new Map<string, Map<string, string | null>>();
private graceIps = new Map<string, Set<string | null>>();
private timers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private io: Server,
private cfg: Config,
private changed: () => void,
) {}
connect(guestId: string, socket: Socket, ip: string | null) {
const timer = this.timers.get(guestId);
if (timer) clearTimeout(timer);
this.timers.delete(guestId);
this.graceIps.delete(guestId);
const sockets = this.connections.get(guestId) ?? new Map();
sockets.set(socket.id, ip);
this.connections.set(guestId, sockets);
socket.join(`guest:${guestId}`);
this.changed();
socket.on('disconnect', () => this.disconnect(guestId, socket.id));
}
private disconnect(guestId: string, id: string) {
const sockets = this.connections.get(guestId);
const oldIp = sockets?.get(id) ?? null;
sockets?.delete(id);
if (sockets?.size) return;
this.graceIps.set(guestId, new Set([oldIp]));
const timer = setTimeout(() => {
this.connections.delete(guestId);
this.graceIps.delete(guestId);
this.timers.delete(guestId);
this.changed();
}, this.cfg.offlineGraceMs);
timer.unref();
this.timers.set(guestId, timer);
}
online(id: string) {
return this.connections.has(id);
}
active(id: string) {
return Boolean(this.connections.get(id)?.size);
}
sameNetwork(a: string, b: string) {
if (!this.cfg.discoveryEnabled) return false;
const groups = (id: string) =>
new Set(
[...(this.connections.get(id)?.values() ?? []), ...(this.graceIps.get(id) ?? [])]
.filter(
(ip) => publicIp(ip) || (!this.cfg.production && (ip === '127.0.0.1' || ip === '::1')),
)
.map((ip) => hmac(this.cfg.discoverySecret, ip!)),
);
const ag = groups(a);
return [...groups(b)].some((v) => ag.has(v));
}
emit(id: string, event: string, payload: unknown) {
this.io.to(`guest:${id}`).emit(event, payload);
}
ids() {
return [...this.connections.keys()].filter((id) => this.online(id));
}
revoke(id: string) {
this.io.in(`guest:${id}`).disconnectSockets(true);
this.connections.delete(id);
this.graceIps.delete(id);
const timer = this.timers.get(id);
if (timer) clearTimeout(timer);
this.timers.delete(id);
this.changed();
}
close() {
for (const t of this.timers.values()) clearTimeout(t);
}
}

View File

@@ -0,0 +1,84 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import ipaddr from 'ipaddr.js';
import type { IncomingHttpHeaders } from 'node:http';
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
}
}
export const fail = (status: number, code: string, message: string): never => {
throw new ApiError(status, code, message);
};
export const randomToken = () => randomBytes(32).toString('base64url');
export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
export const hmac = (secret: string, value: string) =>
createHmac('sha256', secret).update(value).digest('hex');
export function normalizeIp(value: string): string | null {
try {
return ipaddr.process(value).toString();
} catch {
return null;
}
}
export function clientIp(
remote: string | undefined,
headers: IncomingHttpHeaders,
trusted: string[],
): string | null {
const first = remote ? normalizeIp(remote) : null;
if (!first) return null;
const trusts = (v: string) =>
trusted.some((rule) => {
try {
const a = ipaddr.process(v);
const cidr = rule.includes('/')
? ipaddr.parseCIDR(rule)
: ([ipaddr.process(rule), a.kind() === 'ipv4' ? 32 : 128] as [
ipaddr.IPv4 | ipaddr.IPv6,
number,
]);
return a.kind() === cidr[0].kind() && a.match(cidr);
} catch {
return false;
}
});
if (!trusts(first)) return first;
const raw = headers['x-forwarded-for'];
if (typeof raw !== 'string') return null;
const chain = raw.split(',').map((v) => normalizeIp(v.trim()));
if (chain.some((v) => !v)) return null;
let current = first;
for (const next of chain.reverse()) {
if (!trusts(current)) break;
current = next!;
}
return current;
}
export function publicIp(ip: string | null): boolean {
if (!ip) return false;
try {
return ipaddr.process(ip).range() === 'unicast';
} catch {
return false;
}
}
export class Limiter {
private windows = new Map<string, { count: number; until: number }>();
hit(key: string, max: number, windowMs = 60000) {
const now = Date.now();
let w = this.windows.get(key);
if (!w || w.until <= now) {
w = { count: 0, until: now + windowMs };
this.windows.set(key, w);
}
if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.');
}
prune() {
const now = Date.now();
for (const [key, w] of this.windows) if (w.until <= now) this.windows.delete(key);
}
}

1047
apps/server/src/service.ts Normal file

File diff suppressed because it is too large Load Diff

596
apps/server/src/storage.ts Normal file
View File

@@ -0,0 +1,596 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdirSync, statfsSync, existsSync } from 'node:fs';
import { createWriteStream, createReadStream } from 'node:fs';
import { rename, unlink, readdir, stat, open } from 'node:fs/promises';
import { resolve } from 'node:path';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { and, eq, gt, isNull, inArray, sql, or } from 'drizzle-orm';
import sharp from 'sharp';
import type { FastifyRequest } from 'fastify';
import type { Guest, Service } from './service.js';
import * as s from './db/schema.js';
import { ApiError, fail } from './security.js';
export type StorageSubject = { id: string; kind?: 'user'; sessionId?: string };
export class Storage {
private active = new Map<
string,
{ guestId: string; controller: AbortController; fileId: string; sessionId?: string }
>();
private cleaning = false;
private cleanupPromise: Promise<void> | undefined;
readonly temp: string;
readonly originals: string;
constructor(readonly service: Service) {
this.temp = resolve(service.cfg.uploadDir, 'temporary');
this.originals = resolve(service.cfg.uploadDir, 'originals');
mkdirSync(this.temp, { recursive: true, mode: 0o700 });
mkdirSync(this.originals, { recursive: true, mode: 0o700 });
}
private request(g: StorageSubject, id: string) {
return g.kind === 'user'
? this.service.accounts.request(g.id, id)
: this.service.request(g as Guest, id);
}
private access(g: StorageSubject, id: string) {
return g.kind === 'user'
? this.service.accounts.access(g.id, id)
: this.service.accessDetail(g as Guest, id);
}
private blocked(g: StorageSubject, row: ReturnType<Storage['request']>) {
return g.kind === 'user'
? !this.service.accounts.canTransfer(row)
: this.service.isBlocked(g.id, row.recipientGuestId!);
}
private isSender(g: StorageSubject, row: ReturnType<Storage['request']>) {
return g.kind === 'user' ? row.senderUserId === g.id : row.senderGuestId === g.id;
}
fileRows(id: string) {
return this.service.db.select().from(s.files).where(eq(s.files.requestId, id)).all();
}
createManifest(
g: StorageSubject,
requestId: string,
manifest: { name: string; size: number; type: string }[],
) {
for (const [position, file] of manifest.entries())
this.service.db
.insert(s.files)
.values({
id: randomUUID(),
requestId,
ownerGuestId: g.kind === 'user' ? null : g.id,
ownerUserId: g.kind === 'user' ? g.id : null,
position,
name: file.name,
size: file.size,
type: file.type,
storageKey: randomUUID(),
status: 'pending',
updatedAt: Date.now(),
})
.run();
}
private used(guestId?: string) {
return this.service.db
.select({ bytes: sql<number>`coalesce(sum(${s.files.size}),0)` })
.from(s.files)
.where(
and(
inArray(s.files.status, ['stored', 'cleaning']),
sql`${s.files.ownerGuestId} IS NOT NULL`,
guestId ? eq(s.files.ownerGuestId, guestId) : undefined,
),
)
.get()!.bytes;
}
private reserved(guestId?: string) {
return this.service.db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(
and(
gt(s.reservations.expiresAt, Date.now()),
sql`${s.reservations.guestId} IS NOT NULL`,
guestId ? eq(s.reservations.guestId, guestId) : undefined,
),
)
.get()!.bytes;
}
reserve(g: StorageSubject, requestId: string) {
const { db, cfg } = this.service;
const files = this.fileRows(requestId).filter((f) => f.status !== 'stored');
const missing = files.filter(
(file) =>
!db
.select()
.from(s.reservations)
.where(and(eq(s.reservations.fileId, file.id), gt(s.reservations.expiresAt, Date.now())))
.get(),
);
const total = missing.reduce((sum, file) => sum + file.size, 0);
if (g.kind === 'user') {
const reserved = db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(and(eq(s.reservations.userId, g.id), gt(s.reservations.expiresAt, Date.now())))
.get()!.bytes;
this.service.accounts.ensureQuota(g.id, reserved + total);
}
if (g.kind !== 'user' && this.used(g.id) + this.reserved(g.id) + total > cfg.guestQuota)
fail(413, 'GUEST_QUOTA', '이번 기기의 저장 용량을 초과했습니다.');
if (g.kind !== 'user' && this.used() + this.reserved() + total > cfg.totalGuestQuota)
fail(507, 'TOTAL_QUOTA', '현재 서버의 익명 저장 공간이 부족합니다.');
const allReserved = db
.select({ bytes: sql<number>`coalesce(sum(${s.reservations.bytes}),0)` })
.from(s.reservations)
.where(gt(s.reservations.expiresAt, Date.now()))
.get()!.bytes;
const disk = statfsSync(this.originals, { bigint: true });
if (BigInt(allReserved + total + cfg.minFreeBytes) > disk.bavail * disk.bsize)
fail(507, 'DISK_FULL', '서버의 여유 공간이 부족합니다.');
for (const file of missing)
db.insert(s.reservations)
.values({
id: randomUUID(),
fileId: file.id,
guestId: g.kind === 'user' ? null : g.id,
userId: g.kind === 'user' ? g.id : null,
bytes: file.size,
expiresAt: Date.now() + cfg.orphanHours * 3600000,
})
.onConflictDoUpdate({
target: s.reservations.fileId,
set: { expiresAt: Date.now() + cfg.orphanHours * 3600000 },
})
.run();
}
release(requestId: string) {
const db = this.service.db;
for (const file of this.fileRows(requestId)) {
for (const [uploadId, upload] of this.active)
if (upload.fileId === file.id) {
upload.controller.abort();
this.active.delete(uploadId);
}
db.delete(s.reservations).where(eq(s.reservations.fileId, file.id)).run();
if (file.status !== 'stored')
db.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(eq(s.files.id, file.id))
.run();
}
}
async upload(
g: StorageSubject,
requestId: string,
position: number,
request: FastifyRequest,
key: string,
) {
const { db, cfg } = this.service;
let file: typeof s.files.$inferSelect;
let uploadId: string;
const existing = this.fileRows(requestId).find((f) => f.position === position);
if (existing?.status === 'stored') {
const row = this.request(g, requestId);
if (!this.isSender(g, row)) fail(403, 'UPLOAD_FORBIDDEN', '발신자만 업로드할 수 있습니다.');
if (existing.uploadKey !== key) fail(409, 'FILE_STORED', '이미 저장된 파일입니다.');
const checksum = createHash('sha256');
let bytes = 0,
count = 0;
for await (const part of request.parts({
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
})) {
if (part.type !== 'file')
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
count++;
if (part.filename !== existing.name)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
for await (const chunk of part.file) {
bytes += chunk.length;
if (bytes > existing.size)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
checksum.update(chunk);
}
}
if (count !== 1 || bytes !== existing.size || checksum.digest('hex') !== existing.checksum)
fail(409, 'IDEMPOTENCY_CONFLICT', '같은 키의 파일 내용이 다릅니다.');
return { uploadId: existing.uploadId!, fileId: existing.id, status: 'stored' };
}
db.transaction(() => {
const row = this.request(g, requestId);
if (row.kind !== 'file' || !this.isSender(g, row))
fail(403, 'UPLOAD_FORBIDDEN', '이 요청에 파일을 올릴 수 없습니다.');
if (
row.status !== 'accepted' ||
!row.startBy ||
row.startBy <= Date.now() ||
this.blocked(g, row)
)
fail(409, 'ACCEPTANCE_REQUIRED', '수락된 유효한 요청이 필요합니다.');
file = this.fileRows(requestId).find((f) => f.position === position)!;
if (!file) fail(400, 'FILE_NOT_REQUESTED', '요청에 없는 파일입니다.');
if (file.status === 'stored') fail(409, 'FILE_STORED', '이미 저장된 파일입니다.');
if (
file.status === 'uploading' ||
[...this.active.values()].filter((v) => v.guestId === `${g.kind ?? 'guest'}:${g.id}`)
.length >= (g.kind === 'user' ? 2 : 1)
)
fail(409, 'UPLOAD_ACTIVE', '진행 중인 업로드를 먼저 끝내 주세요.');
this.reserve(g, requestId);
uploadId = randomUUID();
db.update(s.files)
.set({ status: 'uploading', uploadId, uploadKey: key, updatedAt: Date.now() })
.where(eq(s.files.id, file.id))
.run();
db.update(s.batches)
.set({ status: 'uploading' })
.where(eq(s.batches.requestId, requestId))
.run();
});
const controller = new AbortController(),
tempPath = resolve(this.temp, uploadId!);
this.active.set(uploadId!, {
guestId: `${g.kind ?? 'guest'}:${g.id}`,
controller,
sessionId: g.sessionId,
fileId: file!.id,
});
request.raw.once('aborted', () => controller.abort());
this.service.notifyTransfer(requestId);
try {
let parts = 0;
let bytes = 0;
const checksum = createHash('sha256');
for await (const part of request.parts({
limits: { files: 1, fields: 0, parts: 1, fileSize: cfg.maxFileBytes + 1 },
})) {
if (part.type !== 'file')
throw new ApiError(400, 'MULTIPART_INVALID', '파일 한 개만 업로드해 주세요.');
parts++;
if (part.filename !== file!.name)
fail(409, 'METADATA_MISMATCH', '요청한 파일 이름과 다릅니다.');
const counter = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length;
if (bytes > file!.size || bytes > cfg.maxFileBytes)
return callback(
new ApiError(413, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.'),
);
checksum.update(chunk);
callback(null, chunk);
},
});
await pipeline(
part.file,
counter,
createWriteStream(tempPath, { flags: 'wx', mode: 0o600 }),
{ signal: controller.signal },
);
if (part.file.truncated || bytes !== file!.size)
fail(409, 'FILE_SIZE_MISMATCH', '파일 크기가 요청과 다릅니다.');
}
if (parts !== 1) fail(400, 'MULTIPART_INVALID', '파일 한 개가 필요합니다.');
const originalPath = resolve(this.originals, file!.storageKey);
await rename(tempPath, originalPath);
try {
db.transaction(() => {
if (g.kind === 'user') {
const session = g.sessionId
? db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, g.sessionId))
.get()
: null;
if (
!session ||
session.userId !== g.id ||
session.revokedAt ||
session.expiresAt <= Date.now()
)
fail(401, 'SESSION_REVOKED', '계정 세션이 종료되었습니다.');
}
const row = this.request(g, requestId),
current = db.select().from(s.files).where(eq(s.files.id, file!.id)).get();
if (
row.status !== 'accepted' ||
this.blocked(g, row) ||
current?.uploadId !== uploadId ||
current.status !== 'uploading'
)
fail(409, 'UPLOAD_CANCELLED', '전송이 취소되었거나 권한이 변경되었습니다.');
const batch = db
.select()
.from(s.batches)
.where(eq(s.batches.requestId, requestId))
.get()!,
now = Date.now(),
itemId = randomUUID();
db.update(s.files)
.set({ status: 'stored', checksum: checksum.digest('hex'), updatedAt: now })
.where(eq(s.files.id, file!.id))
.run();
db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run();
db.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: 'file',
fileId: file!.id,
createdAt: now,
expiresAt: now + cfg.fileRetentionDays * 86400000,
})
.run();
this.service.accounts.createAccess(row, itemId);
const all = this.fileRows(requestId);
db.update(s.batches)
.set({
status: all.every((f) => f.status === 'stored') ? 'stored' : 'waiting',
storedAt: now,
})
.where(eq(s.batches.id, batch.id))
.run();
});
} catch (error) {
await unlink(originalPath).catch(() => {});
throw error;
}
return { uploadId: uploadId!, fileId: file!.id, status: 'stored' };
} catch (error) {
await unlink(tempPath).catch(() => {});
db.delete(s.reservations).where(eq(s.reservations.fileId, file!.id)).run();
db.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(and(eq(s.files.id, file!.id), eq(s.files.uploadId, uploadId!)))
.run();
db.update(s.batches)
.set({ status: 'waiting' })
.where(and(eq(s.batches.requestId, requestId), eq(s.batches.status, 'uploading')))
.run();
if (error instanceof ApiError) throw error;
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOSPC')
fail(507, 'DISK_FULL', '서버 저장 공간이 부족합니다.');
fail(400, 'UPLOAD_FAILED', '업로드가 중단되었습니다. 파일을 다시 선택해 재시도해 주세요.');
} finally {
this.active.delete(uploadId!);
this.service.notifyTransfer(requestId);
}
}
cancel(g: StorageSubject, id: string) {
const file = this.service.db
.select()
.from(s.files)
.where(
and(
eq(s.files.uploadId, id),
g.kind === 'user' ? eq(s.files.ownerUserId, g.id) : eq(s.files.ownerGuestId, g.id),
),
)
.get();
if (!file) fail(404, 'UPLOAD_NOT_FOUND', '업로드를 찾을 수 없습니다.');
if (file!.status !== 'uploading')
fail(409, 'UPLOAD_NOT_ACTIVE', '진행 중인 업로드만 취소할 수 있습니다.');
this.active.get(id)?.controller.abort();
this.service.db
.update(s.files)
.set({ status: 'failed', updatedAt: Date.now() })
.where(eq(s.files.id, file!.id))
.run();
}
download(g: StorageSubject, accessId: string) {
const row = this.access(g, accessId);
if (row.item.kind !== 'file' || !row.item.fileId)
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
const file = this.service.db
.select()
.from(s.files)
.where(and(eq(s.files.id, row.item.fileId!), eq(s.files.status, 'stored')))
.get();
if (!file || !existsSync(resolve(this.originals, file.storageKey)))
fail(404, 'FILE_NOT_FOUND', '파일을 찾을 수 없습니다.');
return { file: file!, path: resolve(this.originals, file!.storageKey) };
}
async preview(g: StorageSubject, id: string) {
const { path } = this.download(g, id);
try {
const handle = await open(path, 'r');
const magic = Buffer.alloc(16);
try {
await handle.read(magic, 0, 16, 0);
} finally {
await handle.close();
}
const raster =
magic.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ||
magic.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
/^GIF8[79]a/.test(magic.toString('ascii')) ||
(magic.toString('ascii', 0, 4) === 'RIFF' && magic.toString('ascii', 8, 12) === 'WEBP') ||
(magic.toString('ascii', 4, 8) === 'ftyp' &&
['avif', 'avis', 'heic', 'heix', 'mif1'].includes(magic.toString('ascii', 8, 12)));
if (!raster) throw Error();
const image = sharp(path, { limitInputPixels: 40000000, animated: false });
const metadata = await image.metadata();
if (!['jpeg', 'png', 'webp', 'gif', 'avif', 'heif'].includes(metadata.format ?? ''))
throw Error();
return await image
.resize(720, 720, { fit: 'inside', withoutEnlargement: true })
.rotate()
.webp({ quality: 78 })
.toBuffer();
} catch {
fail(415, 'PREVIEW_UNSUPPORTED', '이 파일은 이미지 미리보기를 지원하지 않습니다.');
}
}
deleteAccess(g: StorageSubject, ids: string[]) {
const { db, cfg } = this.service;
return db.transaction(() => {
const unique = [...new Set(ids)],
rows = unique.map((id) => this.access(g, id));
const id = randomUUID(),
now = Date.now();
db.insert(s.deletions)
.values({
id,
guestId: g.kind === 'user' ? null : g.id,
userId: g.kind === 'user' ? g.id : null,
createdAt: now,
undoUntil: now + cfg.undoSeconds * 1000,
})
.run();
for (const row of rows) {
db.update(s.access).set({ deletedAt: now }).where(eq(s.access.id, row.access.id)).run();
db.insert(s.deletionTargets)
.values({ id: randomUUID(), operationId: id, accessId: row.access.id })
.run();
}
return { operationId: id, undoUntil: now + cfg.undoSeconds * 1000 };
});
}
undo(g: StorageSubject, id: string) {
const { db } = this.service;
return db.transaction(() => {
const operation = db
.select()
.from(s.deletions)
.where(
and(
eq(s.deletions.id, id),
g.kind === 'user' ? eq(s.deletions.userId, g.id) : eq(s.deletions.guestId, g.id),
),
)
.get();
if (!operation) fail(404, 'DELETE_NOT_FOUND', '삭제 작업을 찾을 수 없습니다.');
if (operation!.restoredAt) return { restored: true };
if (operation!.undoUntil <= Date.now())
fail(409, 'UNDO_EXPIRED', '실행 취소 시간이 지났습니다.');
const targets = db
.select()
.from(s.deletionTargets)
.where(eq(s.deletionTargets.operationId, id))
.all();
let count = 0;
for (const target of targets) {
const row = db
.select({ access: s.access, item: s.items })
.from(s.access)
.innerJoin(s.items, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.access.id, target.accessId),
g.kind === 'user' ? eq(s.access.userId, g.id) : eq(s.access.guestId, g.id),
eq(s.access.deletedAt, operation!.createdAt),
gt(s.items.expiresAt, Date.now()),
),
)
.get();
if (row) {
db.update(s.access).set({ deletedAt: null }).where(eq(s.access.id, row.access.id)).run();
count++;
}
}
db.update(s.deletions).set({ restoredAt: Date.now() }).where(eq(s.deletions.id, id)).run();
return { restored: count > 0 };
});
}
cleanup(): Promise<void> {
if (this.cleanupPromise) return this.cleanupPromise;
this.cleanupPromise = this.performCleanup().finally(() => {
this.cleanupPromise = undefined;
});
return this.cleanupPromise;
}
private async performCleanup() {
if (this.cleaning) return;
this.cleaning = true;
try {
const { db, cfg } = this.service,
now = Date.now();
const expired = db
.select()
.from(s.reservations)
.where(sql`${s.reservations.expiresAt} <= ${now}`)
.all();
for (const r of expired) {
const file = db.select().from(s.files).where(eq(s.files.id, r.fileId)).get();
if (file) this.release(file.requestId);
}
for (const file of db.select().from(s.files).where(eq(s.files.status, 'uploading')).all()) {
if (!file.uploadId || !this.active.has(file.uploadId)) {
db.update(s.files)
.set({ status: 'failed', updatedAt: now })
.where(eq(s.files.id, file.id))
.run();
}
}
const all = db
.select()
.from(s.files)
.where(inArray(s.files.status, ['stored', 'cleaning']))
.all();
for (const file of all) {
const refs = db
.select({ id: s.items.id })
.from(s.items)
.innerJoin(s.access, eq(s.access.itemId, s.items.id))
.where(
and(
eq(s.items.fileId, file.id),
gt(s.items.expiresAt, now),
orLive(now, cfg.undoSeconds),
),
)
.all();
if (refs.length) continue;
db.update(s.files).set({ status: 'cleaning' }).where(eq(s.files.id, file.id)).run();
try {
await unlink(resolve(this.originals, file.storageKey));
} catch (error) {
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT')
continue;
}
db.update(s.files)
.set({ status: 'failed', checksum: null, updatedAt: now })
.where(eq(s.files.id, file.id))
.run();
}
const validKeys = new Set(
db
.select()
.from(s.files)
.where(inArray(s.files.status, ['stored', 'cleaning']))
.all()
.map((f) => f.storageKey),
);
for (const name of await readdir(this.originals)) {
if (validKeys.has(name)) continue;
const path = resolve(this.originals, name),
metadata = await stat(path);
if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {});
}
for (const name of await readdir(this.temp)) {
if (this.active.has(name)) continue;
const path = resolve(this.temp, name);
const metadata = await stat(path);
if (metadata.mtimeMs < now - cfg.orphanHours * 3600000) await unlink(path).catch(() => {});
}
} finally {
this.cleaning = false;
}
}
abortSession(id: string) {
for (const upload of this.active.values())
if (upload.sessionId === id) upload.controller.abort();
}
abortUser(id: string) {
for (const upload of this.active.values())
if (upload.guestId === `user:${id}`) upload.controller.abort();
}
close() {
for (const upload of this.active.values()) upload.controller.abort();
}
}
function orLive(now: number, undoSeconds: number) {
return sql`(${s.access.deletedAt} IS NULL OR ${s.access.deletedAt} > ${now - undoSeconds * 1000})`;
}

141
apps/server/src/vault.js Normal file
View File

@@ -0,0 +1,141 @@
import { randomUUID } from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import * as s from './db/schema.js';
import { fail } from './security.js';
export class Vault {
constructor(service) {
this.service = service;
}
bindReceipt(requestId, current) {
if (!current || !this.service.auth.serialize(current.user).verified) return;
this.service.db
.insert(s.receiptAccounts)
.values({ requestId, userId: current.user.id, sessionId: current.session.id })
.onConflictDoNothing()
.run();
}
receipt(request, itemId) {
const { db, auth } = this.service;
const binding = db
.select()
.from(s.receiptAccounts)
.where(eq(s.receiptAccounts.requestId, request.id))
.get();
if (!binding) return;
const session = db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, binding.sessionId))
.get();
if (
!session ||
session.revokedAt ||
session.expiresAt <= Date.now() ||
session.userId !== binding.userId
)
return;
if (!auth.serialize(auth.user(binding.userId)).verified) return;
db.insert(s.access)
.values({ id: randomUUID(), itemId, userId: binding.userId, role: 'recipient', read: false })
.onConflictDoNothing()
.run();
}
source(requestId) {
return this.service.db
.select()
.from(s.vaultTransfers)
.where(eq(s.vaultTransfers.requestId, requestId))
.get();
}
authorize(source) {
const { db, accounts } = this.service;
const session = db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.id, source.sessionId))
.get();
if (
!session ||
session.userId !== source.userId ||
session.revokedAt ||
session.expiresAt <= Date.now()
)
fail(401, 'SESSION_REVOKED', '보낸 계정의 세션이 종료되었습니다.');
const row = accounts.access(source.userId, source.accessId);
if (!['self', 'recipient'].includes(row.access.role))
fail(403, 'VAULT_REQUIRED', '보관함에 있는 자료만 보낼 수 있습니다.');
if (row.item.fileId) {
const file = db.select().from(s.files).where(eq(s.files.id, row.item.fileId)).get();
if (!file || file.status !== 'stored')
fail(404, 'FILE_NOT_FOUND', '파일이 없거나 만료되었습니다.');
return { ...row, file };
}
return row;
}
forward(guest, current, accessId, targetDeviceId, ip) {
const source = { userId: current.user.id, sessionId: current.session.id, accessId };
const row = this.authorize(source);
const input = row.item.url
? { targetDeviceId, kind: 'url', byteSize: Buffer.byteLength(row.item.url), count: 1 }
: {
targetDeviceId,
kind: 'file',
byteSize: row.file.size,
count: 1,
files: [
{ id: row.file.id, name: row.file.name, size: row.file.size, type: row.file.type },
],
};
const result = this.service.createRequest(guest, input, ip, true);
this.service.db
.insert(s.vaultTransfers)
.values({ ...source, requestId: result.requestId })
.run();
return result;
}
deliver(requestId) {
const source = this.source(requestId);
if (!source) return;
const { db, accounts } = this.service;
const row = this.authorize(source);
const request = db.select().from(s.requests).where(eq(s.requests.id, requestId)).get();
const batch = db.select().from(s.batches).where(eq(s.batches.requestId, requestId)).get();
if (batch.status === 'stored') return;
if (
request.status !== 'accepted' ||
request.startBy <= Date.now() ||
this.service.isBlocked(request.senderGuestId, request.recipientGuestId)
)
fail(409, 'REQUEST_STATE', '유효한 수락이 필요합니다.');
const now = Date.now(),
itemId = randomUUID();
db.insert(s.items)
.values({
id: itemId,
batchId: batch.id,
kind: row.item.kind,
url: row.item.url,
fileId: row.item.fileId,
createdAt: now,
expiresAt: now + 7 * 86400000,
})
.run();
accounts.createAccess(request, itemId);
db.update(s.batches)
.set({ status: 'stored', storedAt: now })
.where(eq(s.batches.id, batch.id))
.run();
}
note(userId, accessId, note) {
const row = this.service.accounts.access(userId, accessId);
if (row.access.role !== 'self')
fail(403, 'VAULT_REQUIRED', '임시 보관함 자료만 수정할 수 있습니다.');
this.service.db
.update(s.access)
.set({ note: note || null })
.where(and(eq(s.access.id, accessId), eq(s.access.userId, userId)))
.run();
this.service.accounts.notify(userId);
}
}

149
apps/server/test/helpers.ts Normal file
View File

@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { io, type Socket } from 'socket.io-client';
import { eq } from 'drizzle-orm';
import { createApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import * as schema from '../src/db/schema.js';
import { clientIp } from '../src/security.js';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export function urlFor(value: string) {
return /^https?:\/\//.test(value)
? value
.split('\n')
.map((url) => new URL(url).href)
.join('\n')
: `https://example.test/${encodeURIComponent(value)}`;
}
const origin = 'http://hop.test';
type Client = { cookie: string; csrf: string; deviceId: string; socket?: Socket; ip: string };
export async function fixture(discovery = false, mailer?: import('../src/auth.js').Mailer) {
const folder = await mkdtemp(join(tmpdir(), 'hop-api-'));
const cfg = {
...loadConfig({
APP_URL: origin,
DATABASE_PATH: ':memory:',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
discoveryEnabled: discovery,
uploadDir: join(folder, 'uploads'),
minFreeBytes: 0,
trustedProxies: ['127.0.0.1', '::1'],
offlineGraceMs: 20,
};
const runtime = await createApp(cfg, { mailer });
await runtime.app.listen({ port: 0, host: '127.0.0.1' });
const address = runtime.app.server.address();
assert(address && typeof address !== 'string');
const url = `http://127.0.0.1:${address.port}`;
const sockets: Socket[] = [];
const call = async (
c: Client | null,
path: string,
method = 'GET',
payload?: unknown,
key?: string,
headers: Record<string, string> = {},
) =>
runtime.app.inject({
method: method as any,
url: `/api${path}`,
headers: {
origin,
...(c ? { cookie: c.cookie, 'x-csrf-token': c.csrf, 'x-forwarded-for': c.ip } : {}),
...(key ? { 'idempotency-key': key } : {}),
...headers,
},
payload,
});
const create = async (ip = '203.0.113.1', online = true) => {
const response = await call(null, '/guest/session', 'POST', {});
assert.equal(response.statusCode, 200);
const result = response.json();
const c: Client = {
cookie: String(response.headers['set-cookie']).split(';')[0]!,
csrf: result.csrfToken,
deviceId: result.device.id,
ip,
};
if (online) await connect(c);
return c;
};
const connect = async (c: Client) => {
const socket = io(url, {
transports: ['websocket'],
reconnection: false,
auth: { csrfToken: c.csrf },
extraHeaders: { Origin: origin, Cookie: c.cookie, 'X-Forwarded-For': c.ip },
});
sockets.push(socket);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(Error('socket connect timeout')), 3000);
socket.once('connect', () => {
clearTimeout(timer);
resolve();
});
socket.once('connect_error', (err) => {
clearTimeout(timer);
reject(err);
});
});
c.socket = socket;
return socket;
};
const invitations = new Map<string, any>();
const space = async (a: Client, b: Client, approvalRequired = false) => {
const current = (await call(a, '/spaces')).json().spaces;
let result = invitations.get(a.deviceId);
if (!result || !current.some((room: any) => room.id === result.space.id)) {
const response = await call(a, '/spaces', 'POST', { approvalRequired });
assert.equal(response.statusCode, 200, response.body);
result = response.json();
invitations.set(a.deviceId, result);
}
const join = await call(b, '/spaces/join', 'POST', { code: result.code });
assert.equal(join.statusCode, 200);
return result;
};
const request = async (
a: Client,
b: Client,
text = '안녕하세요',
kind: 'url' = 'url',
count = 1,
key = randomUUID(),
) => {
const r = await call(
a,
'/transfer-requests',
'POST',
{ targetDeviceId: b.deviceId, kind, byteSize: Buffer.byteLength(urlFor(text)), count },
key,
);
return r;
};
const complete = async (a: Client, b: Client, text = '안녕하세요') => {
const r = await request(a, b, text);
assert.equal(r.statusCode, 200);
const id = r.json().requestId;
assert.equal(
(await call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
assert.equal(
(await call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, randomUUID()))
.statusCode,
200,
);
return id;
};
const close = async () => {
for (const socket of sockets) socket.disconnect();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
};
return { ...runtime, cfg, url, call, create, connect, space, request, complete, close };
}

View File

@@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createResendMailer } from '../src/mail.js';
import { ApiError } from '../src/security.js';
test('Resend 키가 없으면 발송 모듈은 비활성', () => {
assert.equal(createResendMailer({ mailFrom: 'hop. <hello@noname.company>' }), undefined);
});
test('Resend 발송 계약과 고정 중복 키: 키는 서버 Authorization에만 전달', async () => {
const calls: { url: unknown; options: RequestInit | undefined }[] = [];
const fakeFetch = (async (url: unknown, options: RequestInit) => {
calls.push({ url, options });
return new Response(JSON.stringify({ id: 'test-message' }), { status: 200 });
}) as typeof fetch;
const mailer = createResendMailer(
{ resendApiKey: 're_test_fixture', mailFrom: 'hop. <hello@noname.company>' },
fakeFetch,
)!;
await mailer({
to: 'recipient@example.com',
subject: '인증',
text: 'test body',
idempotencyKey: 'auth-verify-fixture',
});
assert.equal(calls.length, 1);
assert.equal(calls[0]!.url, 'https://api.resend.com/emails');
const options = calls[0]!.options!,
headers = new Headers(options.headers);
assert.equal(headers.get('Authorization'), 'Bearer re_test_fixture');
assert.equal(headers.get('Idempotency-Key'), 'auth-verify-fixture');
assert.equal(options.redirect, 'error');
assert(options.signal instanceof AbortSignal);
assert.deepEqual(JSON.parse(String(options.body)), {
from: 'hop. <hello@noname.company>',
to: ['recipient@example.com'],
subject: '인증',
text: 'test body',
});
assert(!String(options.body).includes('re_test_fixture'));
});
test('Resend 거절·타임아웃·잘못된 응답은 민감한 원문 없이 오류를 반환', async () => {
for (const fakeFetch of [
async () =>
new Response(JSON.stringify({ message: 'private provider response re_test_fixture' }), {
status: 401,
}),
async () => {
throw Error('private credential re_test_fixture');
},
async () => new Response('{}', { status: 200 }),
]) {
const mailer = createResendMailer(
{ resendApiKey: 're_test_fixture', mailFrom: 'hop. <hello@noname.company>' },
fakeFetch as typeof fetch,
)!;
await assert.rejects(
mailer({ to: 'recipient@example.com', subject: '인증', text: 'test body' }),
(error) =>
error instanceof ApiError &&
error.code === 'MAIL_DELIVERY_FAILED' &&
error.status === 503 &&
!error.message.includes('re_test_fixture') &&
!error.message.includes('private'),
);
}
});

View File

@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import Database from 'better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { openDatabase } from '../src/db/index.js';
test('사용 중 적용된 초기 계정 DB 버전을 후속 마이그레이션으로 보정하고 기록 보존', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-legacy-'));
try {
const path = join(folder, 'legacy.sqlite'),
old = new Database(path),
migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)',
);
for (const [index, migration] of migrations.slice(0, 5).entries()) {
for (let statement of migration.sql) {
if (index === 4 && statement.includes('CREATE TABLE `__new_deletion_operations`'))
statement = statement.replace('`guestId` text,', '`guestId` text NOT NULL,');
old.exec(statement);
}
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(
index === 4
? 'fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068'
: migration.hash,
index === 4 ? 1791448938074 : migration.folderMillis,
);
}
const now = Date.now();
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run('guest', 'hash', now + 86400000, now);
old
.prepare('INSERT INTO deletion_operations(id,guestId,createdAt,undoUntil) VALUES (?,?,?,?)')
.run('operation', 'guest', now, now + 5000);
old.close();
const { sqlite } = openDatabase(path);
const info = sqlite.pragma('table_info(deletion_operations)') as {
name: string;
notnull: number;
}[];
assert.equal(info.find((c) => c.name === 'guestId')!.notnull, 0);
assert.equal((sqlite.prepare('SELECT count(*) AS n FROM guest_sessions').get() as any).n, 1);
assert.equal(
(sqlite.prepare('SELECT guestId FROM deletion_operations WHERE id=?').get('operation') as any)
.guestId,
'guest',
);
assert.equal((sqlite.pragma('foreign_key_check') as any[]).length, 0);
assert.equal(
(
sqlite
.prepare('SELECT count(*) AS n FROM __drizzle_migrations WHERE hash=?')
.get('fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068') as any
).n,
1,
);
sqlite.close();
const again = openDatabase(path);
assert.equal(
(again.sqlite.prepare('SELECT count(*) AS n FROM guest_sessions').get() as any).n,
1,
);
again.sqlite.close();
} finally {
await rm(folder, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,557 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { io, type Socket } from 'socket.io-client';
import { eq } from 'drizzle-orm';
import { createApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import * as schema from '../src/db/schema.js';
import { clientIp } from '../src/security.js';
import { fixture, urlFor } from './helpers.js';
test('익명 이름과 비밀 쿠키가 유지되고 공개 ID는 인증이 아니다', async () => {
const f = await fixture();
try {
const a = await f.create();
const changed = await f.call(a, '/device/me', 'PATCH', { name: '나의 Mac' });
assert.equal(changed.statusCode, 200);
const again = await f.call(a, '/guest/session', 'POST', {});
assert.equal(again.json().device.name, '나의 Mac');
assert.equal(again.json().device.id, a.deviceId);
assert.equal((await f.call(null, '/device/me')).statusCode, 401);
assert.equal(
(await f.call({ ...a, cookie: `hop_guest=${a.deviceId}` }, '/device/me')).statusCode,
401,
);
assert.match(String(again.headers['set-cookie']), /HttpOnly/);
assert.match(String(again.headers['set-cookie']), /SameSite=Lax/);
} finally {
await f.close();
}
});
test('Origin, CSRF, HTML 이름 검증', async () => {
const f = await fixture();
try {
const a = await f.create();
assert.equal((await f.call(a, '/device/me', 'PATCH', { name: '<script>' })).statusCode, 400);
assert.equal(
(
await f.call(a, '/device/me', 'PATCH', { name: '테스트' }, undefined, {
origin: 'https://evil.test',
})
).statusCode,
403,
);
assert.equal(
(
await f.call(a, '/device/me', 'PATCH', { name: '테스트' }, undefined, {
'x-csrf-token': 'bad',
})
).statusCode,
403,
);
} finally {
await f.close();
}
});
test('검증 전 네트워크 탐색은 꺼지고 QR 코드로 다른 IP 연결', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create('198.51.100.2'),
c = await f.create('198.51.100.3');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
await f.space(a, b);
const peers = (await f.call(a, '/discovery/devices')).json().devices;
assert.deepEqual(
peers.map((d: any) => d.id),
[b.deviceId],
);
assert.deepEqual(peers[0].sources, ['space']);
assert.equal((await f.request(a, c)).statusCode, 403);
} finally {
await f.close();
}
});
test('같은 공인 IP만 발견하고 다른 IP 기기에는 전송 금지', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('1.1.1.1');
assert.deepEqual(
(await f.call(a, '/discovery/devices')).json().devices.map((d: any) => d.id),
[b.deviceId],
);
assert.equal((await f.request(a, c)).statusCode, 403);
} finally {
await f.close();
}
});
test('발견 끄기는 즉시 목록과 새 요청에서 제외', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.call(b, '/device/me', 'PATCH', { discoverable: false });
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
} finally {
await f.close();
}
});
test('승인 전 참여자는 발견되지 않으며 생성자만 승인 가능', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
const invite = await f.space(a, b, true);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(c, `/spaces/${invite.space.id}/members/${b.deviceId}/approve`, 'POST', {}))
.statusCode,
404,
);
assert.equal(
(await f.call(a, `/spaces/${invite.space.id}/members/${b.deviceId}/approve`, 'POST', {}))
.statusCode,
200,
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
} finally {
await f.close();
}
});
test('공간 퇴장, 재가입 거절, 폐쇄, QR 토큰 참여', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = (await f.call(a, '/spaces', 'POST', {})).json();
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { token: invite.token })).statusCode,
200,
);
assert.equal(
(await f.call(a, `/spaces/${invite.space.id}/members/${b.deviceId}`, 'DELETE')).statusCode,
200,
);
assert.equal((await f.request(a, b)).statusCode, 403);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 403);
assert.equal((await f.call(a, `/spaces/${invite.space.id}`, 'DELETE')).statusCode, 200);
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { token: invite.token })).statusCode,
404,
);
} finally {
await f.close();
}
});
test('메타데이터만 수락 전 공개하고 전송 확정 금지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const response = await f.request(a, b, '비밀 본문');
assert.equal(response.statusCode, 200);
const id = response.json().requestId;
assert.equal(
JSON.stringify((await f.call(b, '/transfer-requests')).json()).includes('비밀 본문'),
false,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비밀 본문')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
} finally {
await f.close();
}
});
test('수락 이후 실제 URL 저장, 수신 접근권 격리, 읽음 공유', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.complete(a, b, '안녕\n<script>alert(1)</script>');
const received = (await f.call(b, '/device/items')).json().items[0];
assert.equal(received.url, urlFor('안녕\n<script>alert(1)</script>'));
assert.equal(received.read, false);
assert.equal(
(await f.call(c, `/access/${received.accessId}/read`, 'PATCH', { read: true })).statusCode,
404,
);
await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, true);
await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: false });
assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, false);
} finally {
await f.close();
}
});
test('다중 URL을 개별 항목으로 저장하고 내부 URL 전달을 허용', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const urls = ['https://example.com/', 'http://192.168.1.10/'];
const response = await f.request(a, b, urls.join('\n'), 'url', 2);
const id = response.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const result = await f.call(a, `/transfers/${id}/complete`, 'POST', { urls }, randomUUID());
assert.equal(result.statusCode, 200);
assert.equal((await f.call(b, '/device/items')).json().items.length, 2);
} finally {
await f.close();
}
});
test('URL 스크립트 프로토콜 및 승인 메타데이터 변경 차단', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const r = await f.request(a, b, 'https://example.com/', 'url', 1);
const id = r.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['javascript:alert(1)'] },
randomUUID(),
)
).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['https://example.com/longer'] },
randomUUID(),
)
).statusCode,
409,
);
} finally {
await f.close();
}
});
test('동일 키 요청과 확정은 한 번만 처리하고 다른 내용은 충돌', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const key = randomUUID(),
text = 'repeat';
const one = await f.request(a, b, text, 'url', 1, key),
two = await f.request(a, b, text, 'url', 1, key);
assert.equal(one.json().requestId, two.json().requestId);
assert.equal((await f.request(a, b, 'different', 'url', 1, key)).statusCode, 409);
const id = one.json().requestId,
acceptKey = randomUUID(),
completeKey = randomUUID();
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode,
200,
);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode,
200,
);
await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey);
await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('수신자만 수락하고 송신자만 확정 가능', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
assert.equal(
(await f.call(a, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
403,
);
assert.equal(
(await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
404,
);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(
await f.call(
b,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
403,
);
} finally {
await f.close();
}
});
test('거절, 만료, 취소 후 확정 금지와 거절 재요청 제한', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {});
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.request(a, b)).statusCode, 429);
const c = await f.create();
await f.space(a, c);
const second = (await f.request(a, c)).json().requestId;
f.service.db
.update(schema.requests)
.set({ expiresAt: Date.now() - 1 })
.where(eq(schema.requests.id, second))
.run();
assert.equal(
(await f.call(c, `/transfer-requests/${second}/accept`, 'POST', {}, randomUUID())).statusCode,
409,
);
f.service.cleanup();
assert.equal(
f.service.db.select().from(schema.requests).where(eq(schema.requests.id, second)).get()!
.status,
'expired',
);
} finally {
await f.close();
}
});
test('수락 직전 오프라인 검사 및 여러 탭 합산', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const first = b.socket!;
const second = await f.connect(b);
first.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
const id = (await f.request(a, b)).json().requestId;
second.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
409,
);
} finally {
await f.close();
}
});
test('방 종료 시 진행 중 수락 전송도 취소되며 재연결로 확정할 수 없음', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await f.call(a, `/spaces/${invite.space.id}`, 'DELETE');
b.socket!.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
await f.connect(b);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
} finally {
await f.close();
}
});
test('익명 차단은 새로운 요청과 수락한 요청의 확정을 금지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await f.call(b, `/transfer-requests/${id}/block`, 'POST', {})).statusCode, 200);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
} finally {
await f.close();
}
});
test('익명 초기화는 이전 접근권만 폐기하고 상대 자료는 유지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.complete(a, b);
const response = await f.call(b, '/device/reset', 'POST', {});
assert.equal(response.statusCode, 200);
assert.equal((await f.call(b, '/device/items')).statusCode, 401);
const next = response.json();
const fresh = {
...b,
cookie: String(response.headers['set-cookie']).split(';')[0]!,
csrf: next.csrfToken,
deviceId: next.device.id,
};
assert.equal((await f.call(fresh, '/device/items')).json().items.length, 0);
assert.equal((await f.call(a, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('만료 자료는 조회 불가하고 본문 정리', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.complete(a, b);
f.service.db
.update(schema.items)
.set({ expiresAt: Date.now() - 1 })
.run();
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
f.service.cleanup();
assert.equal(f.service.db.select().from(schema.items).all().length, 0);
} finally {
await f.close();
}
});
test('코드 대입 횟수 제한과 공간 만료 적용', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = (await f.call(a, '/spaces', 'POST', {})).json();
f.service.db
.update(schema.spaces)
.set({ expiresAt: Date.now() - 1 })
.run();
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 404);
for (let i = 0; i < 10; i++) await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' });
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' })).statusCode, 429);
} finally {
await f.close();
}
});
test('임의 X-Forwarded-For는 신뢰하지 않고 명시된 프록시 체인만 해석', () => {
assert.equal(clientIp('8.8.8.8', { 'x-forwarded-for': '1.1.1.1' }, []), '8.8.8.8');
assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': '1.1.1.1' }, ['127.0.0.1']), '1.1.1.1');
assert.equal(clientIp('127.0.0.1', {}, ['127.0.0.1']), null);
assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': 'bogus' }, ['127.0.0.1']), null);
assert.equal(
clientIp('127.0.0.1', { 'x-forwarded-for': '8.8.8.8, 1.1.1.1' }, ['127.0.0.1']),
'1.1.1.1',
);
});
test('신규 일반 텍스트 요청과 본문 확정을 거절하고 URL·파일만 지원', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
assert.equal(
(
await f.call(
a,
'/transfer-requests',
'POST',
{ targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 1 },
randomUUID(),
)
).statusCode,
400,
);
const id = (await f.request(a, b, 'https://example.com/')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: 'hello' }, randomUUID()))
.statusCode,
400,
);
assert.equal((await f.call(b, '/device/snapshot')).json().items.length, 0);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['https://example.com/'] },
randomUUID(),
)
).statusCode,
200,
);
const snapshot = (await f.call(b, '/device/snapshot')).json();
assert.equal(snapshot.items[0].kind, 'url');
assert.equal(snapshot.items[0].url, 'https://example.com/');
assert.equal('text' in snapshot.items[0], false);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,411 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { readFile, mkdtemp, rm, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import Database from 'better-sqlite3';
import sharp from 'sharp';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { openDatabase } from '../src/db/index.js';
async function fileRequest(
f: Awaited<ReturnType<typeof fixture>>,
a: any,
b: any,
files: { name: string; size: number; type: string }[],
) {
const r = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: files.reduce((sum, v) => sum + v.size, 0),
count: files.length,
files,
},
randomUUID(),
);
assert.equal(r.statusCode, 200, r.body);
return r.json().requestId as string;
}
async function upload(
f: Awaited<ReturnType<typeof fixture>>,
a: any,
id: string,
name: string,
bytes: Buffer,
index = 0,
key = randomUUID(),
) {
const boundary = `hop${randomUUID()}`;
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return f.call(a, `/uploads?requestId=${id}&fileIndex=${index}`, 'POST', body, key, {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
test('파일은 수락 전 업로드되지 않으며 실제 스트리밍 저장과 인증 다운로드', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
const bytes = Buffer.from('실제 파일 내용'),
id = await fileRequest(f, a, b, [
{ name: '파일.txt', size: bytes.length, type: 'text/plain' },
]);
assert.equal((await upload(f, a, id, '파일.txt', bytes)).statusCode, 409);
assert.equal((await readdir(f.service.storage.originals)).length, 0);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
const uploaded = await upload(f, a, id, '파일.txt', bytes);
assert.equal(uploaded.statusCode, 200, uploaded.body);
const item = (await f.call(b, '/device/items')).json().items[0];
assert.equal(item.file.name, '파일.txt');
assert.equal((await f.call(c, `/access/${item.accessId}/download`)).statusCode, 404);
const download = await f.call(b, `/access/${item.accessId}/download`);
assert.equal(download.statusCode, 200);
assert.deepEqual(download.rawPayload, bytes);
assert.match(String(download.headers['content-disposition']), /attachment/);
assert.equal(download.headers['x-content-type-options'], 'nosniff');
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
} finally {
await f.close();
}
});
test('선언 크기와 다른 파일, 요청 밖 파일, 이름 변경을 거절하고 임시 파일 회수', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'a.txt', size: 4, type: 'text/plain' }]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('excess'))).statusCode, 413);
assert.equal((await readdir(f.service.storage.temp)).length, 0);
assert.equal((await upload(f, a, id, 'changed.txt', Buffer.from('test'))).statusCode, 409);
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'), 1)).statusCode, 400);
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'))).statusCode, 200);
} finally {
await f.close();
}
});
test('부분 성공은 유지하고 실패 파일만 재시도', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'one.txt', size: 3, type: 'text/plain' },
{ name: 'two.txt', size: 3, type: 'text/plain' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await upload(f, a, id, 'one.txt', Buffer.from('one'))).statusCode, 200);
assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('badsize'), 1)).statusCode, 413);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('two'), 1)).statusCode, 200);
assert.equal((await f.call(b, '/device/items')).json().items.length, 2);
assert.equal(
f.service.storage.fileRows(id).every((v) => v.status === 'stored'),
true,
);
} finally {
await f.close();
}
});
test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지 못한다', async () => {
const f = await fixture();
try {
f.cfg.totalGuestQuota = 4;
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.space(a, c);
const one = await fileRequest(f, a, b, [
{ name: 'one', size: 4, type: 'application/octet-stream' },
]),
two = await fileRequest(f, c, b, [
{ name: 'two', size: 4, type: 'application/octet-stream' },
]);
const results = await Promise.all([
f.call(b, `/transfer-requests/${one}/accept`, 'POST', {}, randomUUID()),
f.call(b, `/transfer-requests/${two}/accept`, 'POST', {}, randomUUID()),
]);
assert.deepEqual(results.map((r) => r.statusCode).sort(), [200, 507]);
assert.equal(
f.service.db
.select()
.from(s.reservations)
.all()
.reduce((sum, r) => sum + r.bytes, 0),
4,
);
} finally {
await f.close();
}
});
test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async () => {
const f = await fixture();
try {
f.cfg.guestQuota = 3;
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'large', size: 4, type: 'application/octet-stream' },
]);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
413,
);
f.cfg.guestQuota = 4;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
await f.call(a, `/transfer-requests/${id}/cancel`, 'POST', {});
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
assert.equal((await upload(f, a, id, 'large', Buffer.from('test'))).statusCode, 409);
} finally {
await f.close();
}
});
test('인증 래스터 미리보기는 읽음이 아니며 SVG와 실행 콘텐츠는 거절', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const png = await sharp({
create: { width: 16, height: 16, channels: 3, background: '#365e40' },
})
.png()
.toBuffer();
const id = await fileRequest(f, a, b, [
{ name: 'test.png', size: png.length, type: 'image/png' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await upload(f, a, id, 'test.png', png);
const item = (await f.call(b, '/device/items')).json().items[0];
const preview = await f.call(b, `/access/${item.accessId}/preview`);
assert.equal(preview.statusCode, 200);
assert.equal(preview.headers['content-type'], 'image/webp');
assert.equal((await f.call(b, '/device/items')).json().items[0].read, false);
const c = await f.create();
await f.space(a, c);
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
);
const svgId = await fileRequest(f, c, b, [
{ name: 'evil.svg', size: svg.length, type: 'image/svg+xml' },
]);
await f.call(b, `/transfer-requests/${svgId}/accept`, 'POST', {}, randomUUID());
await upload(f, c, svgId, 'evil.svg', svg);
const svgItem = (await f.call(b, '/device/items'))
.json()
.items.find((v: any) => v.file.name === 'evil.svg');
assert.equal((await f.call(b, `/access/${svgItem.accessId}/preview`)).statusCode, 415);
} finally {
await f.close();
}
});
test('내 목록 삭제는 상대 접근권을 유지하고 서버 복원 ID로 실행 취소', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.complete(a, b);
const item = (await f.call(b, '/device/items')).json().items[0];
const deletion = (
await f.call(b, '/access/delete-batch', 'POST', { ids: [item.accessId] })
).json();
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
assert.equal((await f.call(a, '/device/items')).json().items.length, 1);
assert.equal(
(await f.call(c, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode,
404,
);
assert.equal(
(await f.call(b, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode,
200,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('실행 취소 기한과 자연 만료를 검사하며 마지막 권한 뒤 파일 정리', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'delete.txt', size: 4, type: 'text/plain' }]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await upload(f, a, id, 'delete.txt', Buffer.from('test'));
const sender = (await f.call(a, '/device/items')).json().items[0],
receiver = (await f.call(b, '/device/items')).json().items[0];
await f.call(a, '/access/delete-batch', 'POST', { ids: [sender.accessId] });
const d = (
await f.call(b, '/access/delete-batch', 'POST', { ids: [receiver.accessId] })
).json();
await f.service.storage.cleanup();
assert.equal((await readdir(f.service.storage.originals)).length, 1);
f.service.db
.update(s.deletions)
.set({ undoUntil: Date.now() - 1 })
.where(eq(s.deletions.id, d.operationId))
.run();
assert.equal(
(await f.call(b, `/deletion-operations/${d.operationId}/undo`, 'POST', {})).statusCode,
409,
);
f.service.db
.update(s.access)
.set({ deletedAt: Date.now() - 6000 })
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.equal((await readdir(f.service.storage.originals)).length, 0);
} finally {
await f.close();
}
});
test('Phase 1 자료가 있는 DB를 Phase 2로 마이그레이션해도 본문과 접근권 유지', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-migrate-'));
try {
const path = join(folder, 'old.sqlite'),
old = new Database(path);
const migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)',
);
for (const migration of migrations.slice(0, 2)) {
for (const sql of migration.sql) old.exec(sql);
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
const now = Date.now();
for (const id of ['a', 'b'])
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run(id, id, now + 86400000, now);
old
.prepare(
'INSERT INTO transfer_requests(id,mode,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?,?)',
)
.run('request', 'local', 'a', 'b', 'text', 4, 1, 'accepted', now, now + 60000);
old
.prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)')
.run('batch', 'request', 'stored');
old
.prepare('INSERT INTO items(id,batchId,kind,text,createdAt,expiresAt) VALUES (?,?,?,?,?,?)')
.run('item', 'batch', 'text', 'keep', now, now + 86400000);
old
.prepare('INSERT INTO item_access(id,itemId,guestId,role,read) VALUES (?,?,?,?,?)')
.run('access', 'item', 'b', 'recipient', 0);
old.close();
const { sqlite } = openDatabase(path);
assert.equal(
(sqlite.prepare('SELECT text FROM items WHERE id=?').get('item') as any).text,
'keep',
);
assert.equal((sqlite.prepare('SELECT count(*) AS n FROM item_access').get() as any).n, 1);
assert.equal(sqlite.pragma('foreign_key_check').length, 0);
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
sqlite.close();
} finally {
await rm(folder, { recursive: true, force: true });
}
});
test('업로드 확정 재시도는 같은 키와 바이트에만 동일 결과를 반환', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'retry.txt', size: 4, type: 'text/plain' }]),
key = randomUUID();
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const first = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key),
second = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key);
assert.equal(first.statusCode, 200);
assert.deepEqual(first.json(), second.json());
assert.equal(
(await upload(f, a, id, 'retry.txt', Buffer.from('diff'), 0, key)).statusCode,
409,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('실제 HTTP 업로드 중단 시 임시 파일과 해당 파일 예약을 회수', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'abort.bin', size: 1048576, type: 'application/octet-stream' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const { Readable } = await import('node:stream');
const controller = new AbortController(),
boundary = 'hop-abort';
async function* bytes() {
yield Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="abort.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`,
);
yield Buffer.alloc(65536);
while (!controller.signal.aborted) await new Promise((r) => setTimeout(r, 10));
}
const pending = fetch(f.url + `/api/uploads?requestId=${id}&fileIndex=0`, {
method: 'POST',
headers: {
Origin: 'http://hop.test',
Cookie: a.cookie,
'X-CSRF-Token': a.csrf,
'Idempotency-Key': randomUUID(),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body: Readable.from(bytes()),
duplex: 'half',
signal: controller.signal,
} as any).catch(() => null);
for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'uploading'; i++)
await new Promise((r) => setTimeout(r, 10));
assert.equal(f.service.storage.fileRows(id)[0]!.status, 'uploading');
controller.abort();
await pending;
for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'failed'; i++)
await new Promise((r) => setTimeout(r, 10));
assert.equal(f.service.storage.fileRows(id)[0]!.status, 'failed');
assert.equal((await readdir(f.service.storage.temp)).length, 0);
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,470 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture, urlFor } from './helpers.js';
import type { Mail } from '../src/auth.js';
import { normalizeEmail } from '../src/auth.js';
import * as s from '../src/db/schema.js';
const password = 'correct horse battery staple';
test('개발은 1자 비밀번호를 허용하고 운영은 12자 규칙을 유지', async () => {
const f = await fixture();
try {
assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 1);
assert.match(await f.service.auth.password('1'), /^\$argon2id\$/);
await assert.rejects(() => f.service.auth.password(''));
f.cfg.production = true;
assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 12);
await assert.rejects(() => f.service.auth.password('1234'), /12~1024/);
assert.match(await f.service.auth.password('123412341234'), /^\$argon2id\$/);
} finally {
await f.close();
}
});
async function accountFixture() {
const mails: Mail[] = [];
const f = await fixture(false, async (mail) => {
mails.push(mail);
});
f.cfg.registrationMode = 'open';
return { ...f, mails };
}
async function register(
f: Awaited<ReturnType<typeof accountFixture>>,
email: string,
verify = true,
) {
const c = await f.create();
const result = await f.call(c, '/auth/register', 'POST', {
email,
password,
passwordConfirmation: password,
displayName: email.split('@')[0],
});
assert.equal(result.statusCode, 200, result.body);
const mail = f.mails.find((m) => m.to === email)!;
const token = mail.text.match(/#verify=([A-Za-z0-9_-]+)/)![1]!;
if (verify)
assert.equal((await f.call(c, '/auth/verify-email', 'POST', { token })).statusCode, 200);
const login = await f.call(c, '/auth/login', 'POST', { email, password });
assert.equal(login.statusCode, 200, login.body);
c.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
c.socket?.disconnect();
await f.connect(c);
return { ...c, userId: login.json().account.id, token };
}
async function request(
f: Awaited<ReturnType<typeof accountFixture>>,
a: any,
email: string,
text = 'remote text',
) {
return legacyRequest(f, a, {
email,
kind: 'url',
byteSize: Buffer.byteLength(urlFor(text)),
count: 1,
});
}
// Reproduce requests already stored by the previous email system; this is not a public creation API.
async function legacyRequest(f: any, a: any, input: any) {
try {
const result = f.service.accounts.create(a.userId, input);
f.service.notifyTransfer(result.requestId);
return { statusCode: 200, body: JSON.stringify(result), json: () => result };
} catch (error: any) {
return {
statusCode: error.status,
body: error.message,
json: () => ({ message: error.message }),
};
}
}
test('가입은 메일 게이트웨이로 인증 토큰을 보내고 해시만 저장, 인증과 로그인 회전', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com', false);
assert.equal(f.mails.length, 1);
const user = f.service.db.select().from(s.users).get()!;
assert.match(user.passwordHash!, /^\$argon2id\$/);
assert.equal(f.service.db.select().from(s.accountTokens).get()!.tokenHash === a.token, false);
assert.equal((await request(f, a, 'alice@example.com')).statusCode, 403);
assert.equal(
(await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode,
200,
);
assert.equal(
(await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode,
400,
);
const oldCookie = a.cookie;
const again = await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password });
assert.equal(again.statusCode, 200);
assert.equal((await f.call({ ...a, cookie: oldCookie }, '/auth/me')).json().account, null);
assert.equal((await f.call(a, '/device/me')).json().device.id, a.deviceId);
} finally {
await f.close();
}
});
test('메일 미설정 상태에서 공개 가입을 열 수 없고 폐쇄 가입은 거절', async () => {
const f = await fixture();
try {
const a = await f.create();
const input = {
email: 'alice@example.com',
password,
passwordConfirmation: password,
displayName: 'Alice',
};
assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 403);
f.cfg.registrationMode = 'open';
assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 503);
} finally {
await f.close();
}
});
test('기존 이메일 자료: 낯선 계정은 메타데이터 요청만 만들고 수락 후 실제 전달 및 허용 관계', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com'),
c = await register(f, 'charlie@example.com');
const response = await request(f, a, 'bob@example.com', '비공개 원격 본문');
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().requiresAcceptance, true);
const id = response.json().requestId;
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
assert.equal(
JSON.stringify((await f.call(b, '/transfer-requests?mode=email')).json()).includes(
'비공개 원격 본문',
),
false,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비공개 원격 본문')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal(
(await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
404,
);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${id}/accept`,
'POST',
{ allowSender: true },
randomUUID(),
)
).statusCode,
200,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비공개 원격 본문')] },
randomUUID(),
)
).statusCode,
200,
);
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(item.url, urlFor('비공개 원격 본문'));
assert.equal((await f.call(c, `/access/${item.accessId}`)).statusCode, 404);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(a, '/sent')).json().items[0].recipientRead, true);
b.socket?.disconnect();
const second = await request(f, a, 'bob@example.com');
assert.equal(second.statusCode, 200);
assert.equal(second.json().requiresAcceptance, false);
assert.equal(
(
await f.call(
a,
`/transfers/${second.json().requestId}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
)
).statusCode,
200,
);
assert.equal((await f.call(b, '/inbox')).json().items.length, 2);
} finally {
await f.close();
}
});
test('자신에게 저장은 요청 수락 없이 내 보관함이며 계정 간 상태 격리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const r = await request(f, a, 'alice@example.com');
assert.equal(r.statusCode, 200);
assert.equal(r.json().requiresAcceptance, false);
await f.call(
a,
`/transfers/${r.json().requestId}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(item.role, 'self');
assert.equal((await f.call(a, '/inbox')).json().items.length, 0);
assert.equal(
(await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true })).statusCode,
404,
);
} finally {
await f.close();
}
});
test('기존 이메일 자료: 차단이 허용보다 우선하며 주소 오류는 일반화', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const id = (await request(f, a, 'bob@example.com')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', { allowSender: true }, randomUUID());
await f.call(b, `/account/rules/${a.userId}`, 'PUT', { action: 'block' });
const blocked = await request(f, a, 'bob@example.com'),
unknown = await request(f, a, 'nobody@example.com');
assert.equal(blocked.statusCode, 400);
assert.equal(unknown.statusCode, 400);
assert.equal(blocked.json().message, unknown.json().message);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
)
).statusCode,
409,
);
} finally {
await f.close();
}
});
test('비밀번호 재설정 토큰은 일회용이며 모든 기존 세션을 무효화', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com');
const another = await f.call(a, '/auth/login', 'POST', {
email: 'alice@example.com',
password,
});
a.cookie =
a.cookie.split('; hop_account=')[0] +
'; ' +
String(another.headers['set-cookie']).split(';')[0];
await f.call(a, '/auth/forgot-password', 'POST', { email: 'alice@example.com' });
const mail = f.mails.at(-1)!,
token = mail.text.match(/#reset-password=([A-Za-z0-9_-]+)/)![1]!,
next = 'a different long password';
const response = await f.call(a, '/auth/reset-password', 'POST', {
token,
password: next,
passwordConfirmation: next,
});
assert.equal(response.statusCode, 200);
assert.equal((await f.call(a, '/auth/me')).json().account, null);
assert.equal(
(
await f.call(a, '/auth/reset-password', 'POST', {
token,
password: next,
passwordConfirmation: next,
})
).statusCode,
400,
);
assert.equal(
(await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password })).statusCode,
401,
);
assert.equal(
(await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password: next }))
.statusCode,
200,
);
} finally {
await f.close();
}
});
test('로그인 중 받은 자료는 받은 항목에 자동 기록하며 익명 가져오기는 제거', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await f.create();
await f.space(a, b);
await f.complete(b, a, '자동 기록');
const item = (await f.call(a, '/device/items')).json().items[0];
assert.equal(
(await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode,
410,
);
const saved = (await f.call(a, '/inbox')).json().items[0];
assert.equal(saved.url, item.url);
assert.equal(saved.expiresAt, item.expiresAt);
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
await f.call(a, '/auth/logout', 'POST', {});
assert.equal((await f.call(a, '/inbox')).statusCode, 401);
} finally {
await f.close();
}
});
test('즐겨찾기 영구 보관 제거와 만료된 자료의 실제 정리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const id = (await request(f, a, 'bob@example.com')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
);
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(
(await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true }))
.statusCode,
410,
);
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.run();
f.service.cleanup();
assert.equal((await f.call(b, '/favorites')).json().items.length, 0);
assert.equal((await f.call(a, '/sent')).json().items.length, 0);
await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: false });
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
} finally {
await f.close();
}
});
test('이메일 정규화는 도메인만 소문자화하고 제공자 점·별칭을 보존', () => {
assert.equal(normalizeEmail(' Alice.Smith+tag@EXAMPLE.COM '), 'Alice.Smith+tag@example.com');
assert.notEqual(normalizeEmail('Alice@example.com'), normalizeEmail('alice@example.com'));
});
async function fileUpload(
f: Awaited<ReturnType<typeof accountFixture>>,
a: any,
id: string,
bytes: Buffer,
) {
const boundary = 'hop-account-file';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="account.txt"\r\nContent-Type: text/plain\r\n\r\n`,
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return f.call(a, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명 상한과 계정 용량을 분리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
f.cfg.totalGuestQuota = 1;
const bytes = Buffer.from('account file');
const response = await legacyRequest(f, a, {
email: 'bob@example.com',
kind: 'file',
byteSize: bytes.length,
count: 1,
files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }],
});
assert.equal(response.statusCode, 200);
const id = response.json().requestId;
assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 409);
f.cfg.userQuota = bytes.length - 1;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
413,
);
f.cfg.userQuota = bytes.length;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
const upload = await fileUpload(f, a, id, bytes);
assert.equal(upload.statusCode, 200, upload.body);
const item = (await f.call(b, '/inbox')).json().items[0],
download = await f.call(b, `/access/${item.accessId}/download`);
assert.deepEqual(download.rawPayload, bytes);
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
assert.equal(
(await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true }))
.statusCode,
410,
);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
} finally {
await f.close();
}
});
test('로그인 수신 파일은 계정에 기록하되 보내는 익명 파일 소유권을 옮기지 않음', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
await f.space(a, b);
const bytes = Buffer.from('move my file');
const r = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: bytes.length,
count: 1,
files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }],
},
randomUUID(),
);
const id = r.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 200);
const item = (await f.call(a, '/device/items')).json().items[0];
assert.equal(
(await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode,
410,
);
assert.equal(
f.service.storage.fileRows(id)[0]!.ownerGuestId,
f.service.deviceGuest(a.deviceId)!.guestId,
);
const received = (await f.call(b, '/inbox')).json().items[0];
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRefreshQueue } from '../../web/src/refreshQueue.js';
test('조회 중 들어온 수락 이벤트를 버리지 않고 최신 상태를 다시 읽는다', async () => {
let status = 'pending',
seen: string[] = [];
let release!: () => void, started!: () => void;
const firstStarted = new Promise<void>((resolve) => {
started = resolve;
});
const pause = new Promise<void>((resolve) => {
release = resolve;
});
const refresh = createRefreshQueue(async () => {
const snapshot = status;
if (!seen.length) {
started();
await pause;
}
seen.push(snapshot);
});
const initial = refresh();
await firstStarted;
status = 'accepted';
const update = refresh();
refresh();
release();
await Promise.all([initial, update]);
assert.deepEqual(seen, ['pending', 'accepted']);
});
test('실패한 조회 다음 요청은 정상 재시도', async () => {
let calls = 0;
const refresh = createRefreshQueue(async () => {
if (++calls === 1) throw Error('network');
});
await assert.rejects(refresh(), /network/);
await refresh();
assert.equal(calls, 2);
});

View File

@@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import Database from 'better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { openDatabase } from '../src/db/index.js';
test('기존 30일·즐겨찾기 자료는 ID·본문·권한을 유지하며 변경 시점부터 최대 7일로 제한', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-retention-'));
try {
const path = join(folder, 'existing.sqlite'),
old = new Database(path);
const migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)',
);
for (const migration of migrations.slice(0, 8)) {
for (const statement of migration.sql) old.exec(statement);
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
const now = Date.now();
for (const id of ['a', 'b'])
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run(id, id, now + 30 * 86400000, now);
old
.prepare(
'INSERT INTO transfer_requests(id,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?)',
)
.run('request', 'a', 'b', 'url', 1, 1, 'accepted', now, now + 60000);
old
.prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)')
.run('batch', 'request', 'stored');
old
.prepare('INSERT INTO items(id,batchId,kind,url,createdAt,expiresAt) VALUES (?,?,?,?,?,?)')
.run('item', 'batch', 'url', 'https://example.com/existing', now, now + 30 * 86400000);
old
.prepare('INSERT INTO item_access(id,itemId,guestId,role,favorite) VALUES (?,?,?,?,?)')
.run('access', 'item', 'b', 'recipient', 1);
old.close();
const migrated = openDatabase(path);
try {
const item = migrated.sqlite.prepare('SELECT * FROM items WHERE id=?').get('item');
assert.equal(item.url, 'https://example.com/existing');
assert(
item.expiresAt <= Date.now() + 7 * 86400000 && item.expiresAt >= now + 7 * 86400000 - 1500,
);
const access = migrated.sqlite.prepare('SELECT * FROM item_access WHERE id=?').get('access');
assert.equal(access.guestId, 'b');
assert.equal(access.favorite, 0);
assert.equal(access.note, null);
assert.equal(migrated.sqlite.pragma('foreign_key_check').length, 0);
assert.equal(migrated.sqlite.pragma('integrity_check', { simple: true }), 'ok');
} finally {
migrated.sqlite.close();
}
} finally {
await rm(folder, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,90 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
test('주변 발견은 방 참여자를 제외하고 한 기기는 한 방만 참여, 방 종료 후 모두 주변으로 복귀', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('8.8.8.8');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 2);
const pending = (await f.request(a, b)).json().requestId;
const room = (await f.call(a, '/spaces', 'POST', {})).json();
assert.equal(
f.service.db.select().from(s.requests).where(eq(s.requests.id, pending)).get().status,
'cancelled',
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(b, '/discovery/devices'))
.json()
.devices.some((device) => device.id === a.deviceId),
false,
);
assert.equal((await f.request(b, a)).statusCode, 403);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: room.code })).statusCode, 200);
assert.equal((await f.call(b, '/discovery/devices')).json().devices.length, 1);
assert.equal((await f.call(b, '/spaces', 'POST', {})).statusCode, 409);
const other = (await f.call(c, '/spaces', 'POST', {})).json();
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: other.code })).statusCode, 409);
assert.throws(
() =>
f.service.db
.insert(s.members)
.values({
id: randomUUID(),
spaceId: other.space.id,
deviceId: b.deviceId,
status: 'approved',
joinedAt: Date.now(),
})
.run(),
/UNIQUE/,
);
assert.equal((await f.call(a, `/spaces/${room.space.id}`, 'DELETE')).statusCode, 200);
assert.equal((await f.call(b, '/spaces')).json().spaces.length, 0);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
await f.call(c, `/spaces/${other.space.id}`, 'DELETE');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 2);
assert.equal((await f.call(b, '/spaces', 'POST', {})).statusCode, 200);
} finally {
await f.close();
}
});
test('승인 대기 참여자도 주변에서 숨고 방장이 승인한 뒤에만 같은 방 전송 가능', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('8.8.8.8');
const room = await f.space(a, b, true);
assert.equal((await f.call(c, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
await f.call(a, `/spaces/${room.space.id}/members/${b.deviceId}/approve`, 'POST', {});
const request = await f.request(a, b);
assert.equal(request.statusCode, 200);
await f.call(
b,
`/transfer-requests/${request.json().requestId}/accept`,
'POST',
{},
randomUUID(),
);
await f.call(b, `/spaces/${room.space.id}/members/${b.deviceId}`, 'DELETE');
assert.equal(
f.service.db
.select()
.from(s.requests)
.where(eq(s.requests.id, request.json().requestId))
.get().status,
'cancelled',
);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,409 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
async function signed(f, client, email) {
const id = randomUUID();
f.service.db
.insert(s.users)
.values({
id,
email,
displayName: email,
passwordHash: await f.service.auth.password('1234'),
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
const login = await f.call(client, '/auth/login', 'POST', { email, password: '1234' });
assert.equal(login.statusCode, 200, login.body);
client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
return id;
}
async function upload(f, client, id, bytes) {
const boundary = 'hop-vault-test';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="saved.txt"\r\nContent-Type: text/plain\r\n\r\n`,
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return f.call(client, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
function limits(f) {
f.cfg.sessionRate = 30;
f.cfg.pairRate = 30;
f.cfg.ipRate = 60;
}
test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장, 익명 과거 자료와 로그아웃 후 도착은 가져오지 않음', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.complete(a, b, 'https://example.com/before');
await signed(f, b, 'receiver@hop.test');
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
await f.complete(a, b, 'https://example.com/after');
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(item.url, 'https://example.com/after');
assert.equal(item.role, 'recipient');
assert.equal((await f.call(b, '/vault')).json().items.length, 0);
assert(item.expiresAt - item.createdAt <= 7 * 86400000);
const guestItem = (await f.call(b, '/device/items'))
.json()
.items.find((i) => i.url === item.url);
await f.call(b, `/access/${guestItem.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(b, '/inbox')).json().items[0].read, true);
const request = (await f.request(a, b, 'https://example.com/logged-out')).json().requestId;
await f.call(b, `/transfer-requests/${request}/accept`, 'POST', {}, randomUUID());
await f.call(b, '/auth/logout', 'POST', {});
await f.call(
a,
`/transfers/${request}/complete`,
'POST',
{ urls: ['https://example.com/logged-out'] },
randomUUID(),
);
const login = await f.call(b, '/auth/login', 'POST', {
email: 'receiver@hop.test',
password: '1234',
});
b.cookie =
b.cookie.split('; hop_account=')[0] +
'; ' +
String(login.headers['set-cookie']).split(';')[0];
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
} finally {
await f.close();
}
});
test('임시 링크 보관: 메모, 7일, 중복 방지, 타인 접근 차단과 이메일 전송 제거', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'other@hop.test');
const key = randomUUID(),
input = { kind: 'url', urls: ['https://example.com/saved'], note: '나중에 읽을 글' };
const result = await f.call(a, '/vault', 'POST', input, key);
assert.equal(result.statusCode, 200, result.body);
assert.equal(
(await f.call(a, '/vault', 'POST', input, key)).json().requestId,
result.json().requestId,
);
const list = (await f.call(a, '/vault')).json().items;
assert.equal(list.length, 1);
const item = list[0];
assert.equal(item.note, input.note);
assert.equal(item.expiresAt - item.createdAt, 7 * 86400000);
assert.equal((await f.call(a, '/inbox')).json().items.length, 0);
assert.equal(
(await f.call(b, `/vault/${item.accessId}/note`, 'PATCH', { note: 'stolen' })).statusCode,
404,
);
assert.equal(
(await f.call(a, `/vault/${item.accessId}/note`, 'PATCH', { note: '새 메모' })).statusCode,
200,
);
assert.equal((await f.call(a, '/vault?search=새')).json().items.length, 1);
assert.equal(
(await f.call(a, '/vault', 'POST', { ...input, note: '줄\n바꿈' }, randomUUID())).statusCode,
400,
);
assert.equal(
(await f.call(a, '/vault', 'POST', { kind: 'file', files: [] }, randomUUID())).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['javascript:alert(1)'] },
randomUUID(),
)
).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
'/transfer-requests',
'POST',
{ mode: 'email', email: 'other@hop.test', kind: 'url', count: 1, byteSize: 1 },
randomUUID(),
)
).statusCode,
410,
);
} finally {
await f.close();
}
});
test('보관함 링크 전송은 수락 전 비공개이며 수락 후 서버에서 전달, 삭제된 원본과 계정 세션 변경은 차단', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create(),
c = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'receiver@hop.test');
await signed(f, c, 'other@hop.test');
await f.space(a, b);
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/private'] },
randomUUID(),
);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(
(
await f.call(
c,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
)
).statusCode,
404,
);
const sent = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(sent.statusCode, 200, sent.body);
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
const requests = (await f.call(b, '/transfer-requests')).json().requests;
assert(!JSON.stringify(requests).includes(item.url));
assert.equal(
(
await f.call(
b,
`/transfer-requests/${sent.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
assert.equal((await f.call(b, '/inbox')).json().items[0].url, item.url);
const next = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
await f.call(a, '/access/delete-batch', 'POST', { ids: [item.accessId] });
assert.equal(
(
await f.call(
b,
`/transfer-requests/${next.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
404,
);
f.service.cleanup();
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
await f.call(a, `/transfer-requests/${next.json().requestId}/cancel`, 'POST', {});
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/session'] },
randomUUID(),
);
const another = (await f.call(a, '/vault')).json().items[0];
const revokedSource = await f.call(
a,
`/vault/${another.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
await f.call(a, '/auth/logout', 'POST', {});
assert.equal(
(
await f.call(
b,
`/transfer-requests/${revokedSource.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
401,
);
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
} finally {
await f.close();
}
});
test('보관함 파일은 저장된 바이트를 재사용, 수신 계정 기록·다운로드·원본 만료 후 상대 권한 유지와 마지막 참조 정리', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'receiver@hop.test');
await f.space(a, b);
const bytes = Buffer.from('stored vault file');
const saved = await f.call(
a,
'/vault',
'POST',
{
kind: 'file',
files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }],
note: '업무 자료',
},
randomUUID(),
);
assert.equal(saved.statusCode, 200, saved.body);
assert.equal((await upload(f, a, saved.json().requestId, bytes)).statusCode, 200);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(item.note, '업무 자료');
const sent = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(sent.statusCode, 200, sent.body);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${sent.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
const received = (await f.call(b, '/inbox')).json().items[0];
assert.equal(received.file.id, item.file.id);
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length);
const file = f.service.db.select().from(s.files).where(eq(s.files.id, item.file.id)).get();
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.where(eq(s.items.id, item.id))
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.equal((await f.call(b, `/access/${received.accessId}/download`)).statusCode, 404);
assert.equal(existsSync(resolve(f.service.storage.originals, file.storageKey)), false);
assert.equal(f.service.db.select().from(s.items).all().length, 0);
f.service.db
.update(s.requests)
.set({ createdAt: Date.now() - 8 * 86400000, note: '만료된 메모', manifest: '[]' })
.where(eq(s.requests.id, saved.json().requestId))
.run();
f.service.cleanup();
const expired = f.service.db
.select()
.from(s.requests)
.where(eq(s.requests.id, saved.json().requestId))
.get();
assert.equal(expired.note, null);
assert.equal(expired.manifest, null);
} finally {
await f.close();
}
});
test('보관함 콘텐츠에는 직접 저장과 받은 항목이 포함되며 자기 수신 기록도 같은 방 기기에 전달 가능', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create(),
c = await f.create();
await signed(f, a, 'a@hop.test');
await signed(f, b, 'b@hop.test');
await signed(f, c, 'c@hop.test');
await f.space(a, b);
await f.space(a, c);
await f.complete(a, b, 'https://example.com/received-source');
await f.call(
b,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/saved-source'] },
randomUUID(),
);
const sources = (await f.call(b, '/vault/contents')).json().items;
assert.equal(sources.length, 2);
assert(sources.some((item) => item.role === 'recipient'));
assert(sources.some((item) => item.role === 'self'));
const received = sources.find((item) => item.role === 'recipient');
const request = await f.call(
b,
`/vault/${received.accessId}/send`,
'POST',
{ targetDeviceId: c.deviceId },
randomUUID(),
);
assert.equal(request.statusCode, 200, request.body);
assert.equal(
(
await f.call(
c,
`/transfer-requests/${request.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
assert.equal(
(await f.call(c, '/inbox')).json().items[0].url,
'https://example.com/received-source',
);
assert.equal((await f.call(a, '/vault/contents')).json().items.length, 0);
} finally {
await f.close();
}
});

11
apps/server/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"allowJs": true
},
"include": ["src"]
}

14
apps/web/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<title>hop. — 기기 사이를 가볍게.</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

22
apps/web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "@hop/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build"
},
"dependencies": {
"@hop/shared": "0.1.0",
"@lucide/vue": "^1.53.0",
"qrcode": "^1.5.4",
"socket.io-client": "^4.8.3",
"vue": "^3.5.28"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.0",
"@vitejs/plugin-vue": "^6.0.3",
"tailwindcss": "^4.2.0",
"vite": "^7.3.1"
}
}

1
apps/web/public/icon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#111111"/><path d="M18 14v36h8V34c0-8 12-8 12 0v16h8V32c0-15-13-17-20-9v-9z" fill="#fff"/><circle cx="52" cy="48" r="4" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@@ -0,0 +1,753 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import {
ArrowUpRight,
ArrowRight,
Inbox,
Send,
LogOut,
Copy,
Download,
Trash2,
Settings,
Check,
X,
RotateCcw,
Plus,
Link,
File as FileIcon,
Pencil,
} from '@lucide/vue';
import SendComposer from './SendComposer.vue';
import { createRefreshQueue } from './refreshQueue';
import {
api,
account,
registrationOpen,
passwordMinLength,
refreshAccount,
reconnect,
showError,
refresh,
uploadFile,
clearAccountUploads,
cancelTransfer,
uploadProgress,
} from './client';
const props = defineProps({ initialView: { type: String, default: 'vault' } });
const emit = defineEmits(['devices', 'send-item']);
const view = ref(props.initialView === 'inbox' ? 'inbox' : 'vault');
const authMode = ref('login'),
email = ref(''),
password = ref(''),
confirmation = ref(''),
displayName = ref(''),
token = ref(''),
authMessage = ref('');
const busy = ref(false),
notice = ref(''),
items = ref([]),
nextCursor = ref(null),
search = ref(''),
selected = ref([]),
undo = ref([]),
now = ref(Date.now());
const compose = ref(false),
saveKind = ref(null),
note = ref(''),
saveTransfer = ref(null),
preferencesOpen = ref(false),
editing = ref(null),
noteDraft = ref('');
const storage = ref({ usedBytes: 0, limitBytes: 0 });
const securityAction = ref('none'),
currentPassword = ref(''),
newPassword = ref(''),
newConfirmation = ref(''),
newEmail = ref('');
let timer;
function message(text) {
notice.value = text;
setTimeout(() => {
if (notice.value === text) notice.value = '';
}, 4000);
}
async function act(fn) {
if (busy.value) return;
busy.value = true;
try {
await fn();
} catch (error) {
showError(error);
} finally {
busy.value = false;
}
}
function size(value) {
return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
: value >= 1024
? (value / 1024).toFixed(1) + ' KiB'
: value + ' B';
}
function date(value) {
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(value);
}
const groups = computed(() => {
const grouped = new Map();
for (const item of items.value) {
const day = new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(item.createdAt);
if (!grouped.has(day)) grouped.set(day, []);
grouped.get(day).push(item);
}
return [...grouped].map(([date, items]) => ({ date, items }));
});
function clearPrivate() {
items.value = [];
nextCursor.value = null;
selected.value = [];
search.value = '';
undo.value = [];
compose.value = false;
saveTransfer.value = null;
note.value = '';
editing.value = null;
preferencesOpen.value = false;
password.value = '';
confirmation.value = '';
currentPassword.value = '';
newPassword.value = '';
newConfirmation.value = '';
newEmail.value = '';
securityAction.value = 'none';
}
const refreshLists = createRefreshQueue(async () => {
if (!account.value?.verified) return;
const owner = account.value.id;
const query = new URLSearchParams();
if (search.value) query.set('search', search.value);
const [list, quota] = await Promise.all([
api(`/${view.value}?${query}`),
api('/account/storage'),
]);
if (account.value?.id !== owner) return;
items.value = list.items;
nextCursor.value = list.nextCursor;
storage.value = quota;
});
watch(view, () => {
selected.value = [];
search.value = '';
void refreshLists().catch(showError);
});
watch(
() => account.value?.id,
(id, previous) => {
if (id !== previous) clearPrivate();
},
);
function tokenFromUrl() {
const params = new URLSearchParams(location.hash.slice(1));
for (const [key, mode] of [
['verify', 'verify'],
['reset-password', 'reset'],
['verify-email-change', 'email-change'],
]) {
const value = params.get(key);
if (value) {
token.value = value;
authMode.value = mode;
history.replaceState(null, '', location.pathname);
break;
}
}
}
async function authSubmit() {
await act(async () => {
authMessage.value = '';
if (authMode.value === 'login') {
await api('/auth/login', 'POST', { email: email.value, password: password.value });
clearPrivate();
await refreshAccount();
reconnect();
await refreshLists();
return;
}
if (authMode.value === 'register') {
const result = await api('/auth/register', 'POST', {
email: email.value,
password: password.value,
passwordConfirmation: confirmation.value,
displayName: displayName.value,
});
authMessage.value = result.message;
password.value = '';
confirmation.value = '';
return;
}
if (authMode.value === 'forgot') {
const result = await api('/auth/forgot-password', 'POST', {
email: email.value,
});
authMessage.value = result.message;
return;
}
if (authMode.value === 'verify' || authMode.value === 'email-change') {
await api(
authMode.value === 'verify' ? '/auth/verify-email' : '/auth/verify-email-change',
'POST',
{ token: token.value },
);
token.value = '';
authMode.value = 'login';
authMessage.value = '이메일 인증을 완료했습니다. 로그인해 주세요.';
await refreshAccount();
reconnect();
await refreshLists();
return;
}
await api('/auth/reset-password', 'POST', {
token: token.value,
password: password.value,
passwordConfirmation: confirmation.value,
});
token.value = '';
authMode.value = 'login';
authMessage.value = '비밀번호를 변경했습니다. 다시 로그인해 주세요.';
password.value = '';
confirmation.value = '';
});
}
async function save(values) {
if (busy.value || saveTransfer.value) return;
busy.value = true;
let id;
try {
const result = await api(
'/vault',
'POST',
{
kind: values.files ? 'file' : 'url',
...(values.files
? {
files: values.files.map((file) => ({
name: file.name,
size: file.size,
type: file.type || 'application/octet-stream',
})),
}
: { urls: values.urls }),
note: note.value,
},
crypto.randomUUID(),
);
id = result.requestId;
if (values.files) {
saveTransfer.value = { id, kind: 'file', status: 'accepted' };
busy.value = false;
for (const [index, file] of values.files.entries()) {
if (saveTransfer.value?.id !== id) return;
await uploadFile(id, index, file, account.value.id);
}
if (saveTransfer.value?.id !== id) return;
}
await refreshLists();
compose.value = false;
note.value = '';
message('임시 보관함에 저장했습니다.');
} catch (error) {
if (!id || saveTransfer.value?.id === id || !values.files) showError(error);
} finally {
busy.value = false;
if (saveTransfer.value?.id === id) saveTransfer.value = null;
}
}
async function cancelSave() {
const id = saveTransfer.value?.id;
if (!id) return;
await act(async () => {
saveTransfer.value = null;
await cancelTransfer(id);
await refreshLists();
});
}
async function read(item, value = true) {
await api(`/access/${item.accessId}/read`, 'PATCH', { read: value });
await refreshLists();
}
function recordLinkOpen(item) {
void read(item).catch(showError);
}
async function copy(item) {
await act(async () => {
await navigator.clipboard.writeText(item.url);
await read(item);
message('복사했습니다.');
});
}
async function download(item) {
const a = document.createElement('a');
a.href = `/api/access/${item.accessId}/download`;
a.download = item.file.name;
document.body.append(a);
a.click();
a.remove();
await read(item);
}
async function remove(ids) {
await act(async () => {
const operation = await api('/access/delete-batch', 'POST', { ids });
undo.value.push(operation);
selected.value = [];
await refreshLists();
});
}
async function restore(operation) {
await act(async () => {
await api(`/deletion-operations/${operation.operationId}/undo`, 'POST', {});
undo.value = undo.value.filter((v) => v !== operation);
await refreshLists();
});
}
async function loadMoreAccount() {
await act(async () => {
if (!nextCursor.value) return;
const result = await api(
`/${view.value}?cursor=${encodeURIComponent(nextCursor.value)}&search=${encodeURIComponent(search.value)}`,
);
items.value.push(...result.items);
nextCursor.value = result.nextCursor;
});
}
async function saveNote(item) {
await act(async () => {
await api(`/vault/${item.accessId}/note`, 'PATCH', { note: noteDraft.value });
editing.value = null;
await refreshLists();
});
}
async function securitySubmit() {
await act(async () => {
if (securityAction.value === 'password') {
await api('/auth/change-password', 'POST', {
currentPassword: currentPassword.value,
password: newPassword.value,
passwordConfirmation: newConfirmation.value,
});
clearPrivate();
await refreshAccount();
reconnect();
message('비밀번호를 변경했습니다. 다시 로그인해 주세요.');
} else {
await api('/auth/change-email', 'POST', {
email: newEmail.value,
password: currentPassword.value,
});
currentPassword.value = '';
newEmail.value = '';
securityAction.value = 'none';
message('새 주소의 인증 메일을 확인해 주세요. 인증 후 변경됩니다.');
}
});
}
async function logout() {
await act(async () => {
clearAccountUploads(account.value?.id);
await api('/auth/logout', 'POST', {});
clearPrivate();
await refreshAccount();
reconnect();
await refresh();
});
}
const revoked = () => {
clearPrivate();
void refreshAccount().catch(showError);
};
const updated = () => void refreshLists().catch(showError);
onMounted(async () => {
tokenFromUrl();
timer = setInterval(() => {
now.value = Date.now();
}, 1000);
window.addEventListener('hop-account-updated', updated);
window.addEventListener('hop-account-revoked', revoked);
window.addEventListener('hashchange', tokenFromUrl);
await act(async () => {
await refreshAccount();
await refreshLists();
});
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener('hop-account-updated', updated);
window.removeEventListener('hop-account-revoked', revoked);
window.removeEventListener('hashchange', tokenFromUrl);
clearPrivate();
});
async function resendVerification() {
await act(async () => {
const response = await api('/auth/resend-verification', 'POST', { email: account.value.email });
message(response.message);
});
}
</script>
<template>
<section v-if="!account || token" class="account-auth">
<div class="eyebrow">ACROSS YOUR DEVICES</div>
<h1>
{{
{
login: '다시 가볍게.',
register: '이메일로 시작하기.',
forgot: '비밀번호 찾기.',
verify: '이메일 인증.',
reset: '새 비밀번호.',
'email-change': '이메일 변경 인증.',
}[authMode]
}}
</h1>
<p>계정으로 저장하고, 다른 기기에서도 꺼내 쓰세요.</p>
<form @submit.prevent="authSubmit">
<label v-if="['login', 'register', 'forgot'].includes(authMode)"
>이메일<input
type="email"
v-model="email"
required
autocomplete="email"
placeholder="you@example.com" /></label
><label v-if="authMode === 'register'"
>표시 이름<input
v-model="displayName"
required
maxlength="40"
autocomplete="nickname" /></label
><label v-if="['login', 'register', 'reset'].includes(authMode)"
>비밀번호<input
type="password"
v-model="password"
required
:minlength="authMode === 'login' ? 1 : passwordMinLength"
maxlength="1024"
:autocomplete="authMode === 'login' ? 'current-password' : 'new-password'"
:placeholder="authMode === 'login' ? '비밀번호' : `${passwordMinLength}자 이상`" /></label
><label v-if="['register', 'reset'].includes(authMode)"
>비밀번호 확인<input
type="password"
v-model="confirmation"
required
:minlength="passwordMinLength"
maxlength="1024"
autocomplete="new-password"
/></label>
<p v-if="authMessage" class="auth-message" role="status">{{ authMessage }}</p>
<button class="button primary" type="submit" :disabled="busy">
{{
{
login: '로그인',
register: '가입하고 인증 메일 받기',
forgot: '안내 메일 받기',
verify: '이메일 인증 완료하기',
reset: '비밀번호 변경하기',
'email-change': '이메일 변경 확정하기',
}[authMode]
}}<ArrowRight :size="17" />
</button>
</form>
<div class="auth-links">
<button v-if="authMode === 'login'" class="text-button" @click="authMode = 'forgot'">
비밀번호 찾기</button
><button
v-if="authMode === 'login' && registrationOpen"
class="text-button"
@click="authMode = 'register'"
>
이메일로 가입</button
><button
v-if="authMode !== 'login'"
class="text-button"
@click="
authMode = 'login';
token = '';
"
>
로그인으로 돌아가기
</button>
</div>
<p v-if="!registrationOpen && authMode === 'login'" class="muted">
현재 공개 가입은 닫혀 있습니다.
</p>
<button class="text-button" @click="emit('devices')">
가입 없이 기기로 보내기 <ArrowUpRight :size="15" />
</button>
</section>
<section v-else-if="!account.verified" class="account-auth">
<h1>이메일을 확인해 주세요<span>.</span></h1>
<p>{{ account.email }} 인증 후 이메일 전송과 계정 보관함을 사용할 수 있어요.</p>
<button class="button" @click="resendVerification">인증 메일 다시 받기</button
><button class="text-button" @click="logout">로그아웃</button>
</section>
<section v-else class="account-workspace compact-workspace">
<div class="account-heading compact-heading">
<div>
<h1>보관함</h1>
<p>
{{
view === 'inbox'
? '로그인 중 받은 링크와 파일을 날짜별로 모았어요.'
: '나중에 보내거나 꺼내 쓸 링크와 파일을 담아두세요.'
}}
· 최대 7일
</p>
</div>
<div>
<button class="icon-button" @click="preferencesOpen = true" aria-label="계정 설정">
<Settings :size="18" /></button
><button class="icon-button" @click="logout" aria-label="로그아웃">
<LogOut :size="18" /></button
><button
v-if="view === 'vault'"
class="button primary"
@click="
compose = true;
saveKind = null;
note = '';
"
>
<Plus :size="17" />보관함에 담기
</button>
</div>
</div>
<div class="account-tabs" role="tablist" aria-label="보관함 목록">
<button
role="tab"
:aria-selected="view === 'vault'"
:class="{ active: view === 'vault' }"
@click="view = 'vault'"
>
임시 저장</button
><button
role="tab"
:aria-selected="view === 'inbox'"
:class="{ active: view === 'inbox' }"
@click="view = 'inbox'"
>
받은 항목
</button>
</div>
<form class="compact-search" @submit.prevent="act(refreshLists)">
<input
type="search"
v-model="search"
placeholder="링크, 파일, 메모 검색"
aria-label="계정 자료 검색"
/><button class="button small">검색</button>
</form>
<button v-if="selected.length" class="button bulk-delete" @click="remove(selected)">
<Trash2 :size="16" />선택 삭제
</button>
<section v-for="group in groups" :key="group.date" class="dated-items">
<h2>{{ group.date }} {{ view === 'inbox' ? '받은 항목' : '저장한 항목' }}</h2>
<article v-for="item in group.items" :key="item.accessId" class="item-card compact-item">
<div class="item-heading">
<input
type="checkbox"
v-model="selected"
:value="item.accessId"
:aria-label="`${item.file?.name ?? item.url} 선택`"
/><span>{{ item.file ? '파일' : '링크' }}</span
><span v-if="view === 'inbox'">{{ item.peerName }}</span
><time>{{ date(item.createdAt) }}</time>
</div>
<strong v-if="item.note" class="item-note">{{ item.note }}</strong>
<div v-if="item.file" class="file-item-body">
<FileIcon :size="24" />
<div>
<strong>{{ item.file.name }}</strong
><span>{{ size(item.file.size) }}</span>
</div>
</div>
<p v-else class="item-body">{{ item.url }}</p>
<form v-if="editing === item.accessId" class="note-editor" @submit.prevent="saveNote(item)">
<input v-model="noteDraft" maxlength="200" aria-label="한 줄 메모 수정" /><button
class="button small"
:disabled="busy"
>
저장</button
><button type="button" class="text-button" @click="editing = null">취소</button>
</form>
<div class="item-footer">
<span>{{ date(item.expiresAt) }} 삭제</span>
<div>
<button class="button small" @click="emit('send-item', item)">
<Send :size="14" />기기로 보내기</button
><button v-if="item.file" class="button small" @click="act(() => download(item))">
<Download :size="14" />다운로드</button
><template v-else
><button class="button small" @click="copy(item)"><Copy :size="14" />복사</button
><a
class="button small"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
@click="recordLinkOpen(item)"
><ArrowUpRight :size="14" />열기</a
></template
><button
v-if="view === 'vault'"
class="icon-button"
aria-label="메모 수정"
@click="
editing = item.accessId;
noteDraft = item.note ?? '';
"
>
<Pencil :size="15" /></button
><button
class="icon-button"
aria-label="계정 목록에서 삭제"
@click="remove([item.accessId])"
>
<Trash2 :size="15" />
</button>
</div>
</div>
</article>
</section>
<div v-if="!items.length" class="list-empty">
<Inbox :size="28" />
<h3>{{ view === 'inbox' ? '아직 받은 항목이 없어요.' : '임시 보관함이 비어 있어요.' }}</h3>
<p>
{{
view === 'inbox'
? '로그인한 상태로 기기에서 받으면 여기에 남습니다.'
: '링크나 파일에 한 줄 메모를 더해 담아두세요.'
}}
</p>
</div>
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
<p class="scope-note">
저장 용량 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }}
</p>
</section>
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">
<section
class="modal vault-save-dialog"
role="dialog"
aria-modal="true"
aria-label="보관함에 담기"
>
<header>
<h2>보관함에 담기</h2>
<button
class="icon-button"
:disabled="Boolean(saveTransfer) || busy"
@click="compose = false"
aria-label="보관함 저장 닫기"
>
<X :size="20" />
</button>
</header>
<div v-if="!saveKind" class="save-kind-choices">
<button class="button" @click="saveKind = 'url'"><Link :size="22" />링크 저장</button
><button class="button" @click="saveKind = 'file'"><FileIcon :size="22" />파일 저장</button>
</div>
<template v-else>
<label class="vault-note-label"
>제목 / 한 줄 메모 (선택)<input
v-model="note"
maxlength="200"
aria-label="한 줄 메모"
placeholder="예: 나중에 읽을 글"
:disabled="Boolean(saveTransfer) || busy" /></label
><SendComposer
target-name="임시 보관함"
target-key="vault-save"
:save-mode="true"
:content-mode="saveKind"
:read-clipboard-on-selection="true"
:busy="busy"
:transfer="saveTransfer"
:progress="
Object.entries(uploadProgress)
.filter(([key]) => key.startsWith(saveTransfer?.id + ':'))
.map(([, value]) => value)
"
@links="save({ urls: $event })"
@files="save({ files: $event })"
@cancel="cancelSave"
/>
<button
class="text-button"
:disabled="Boolean(saveTransfer) || busy"
@click="saveKind = null"
>
다른 종류 선택
</button></template
>
</section>
</div>
<div v-if="preferencesOpen" class="modal-backdrop" @click.self="preferencesOpen = false">
<section class="modal" role="dialog" aria-modal="true" aria-label="계정 설정">
<header>
<h2>계정 설정</h2>
<button class="icon-button" @click="preferencesOpen = false" aria-label="계정 설정 닫기">
<X :size="20" />
</button>
</header>
<p>{{ account.email }}</p>
<select v-model="securityAction" aria-label="계정 정보 변경">
<option value="none">변경할 항목 선택</option>
<option value="password">비밀번호 변경</option>
<option value="email">이메일 변경</option>
</select>
<form v-if="securityAction !== 'none'" @submit.prevent="securitySubmit">
<label
>현재 비밀번호<input
type="password"
v-model="currentPassword"
required
autocomplete="current-password" /></label
><template v-if="securityAction === 'password'"
><label
>새 비밀번호<input
type="password"
v-model="newPassword"
:minlength="passwordMinLength"
required
autocomplete="new-password" /></label
><label
>새 비밀번호 확인<input
type="password"
v-model="newConfirmation"
:minlength="passwordMinLength"
required
autocomplete="new-password" /></label></template
><label v-else>새 이메일<input type="email" v-model="newEmail" required /></label
><button class="button primary" :disabled="busy">변경</button>
</form>
</section>
</div>
<div v-if="notice" class="toast" role="status"><Check :size="16" />{{ notice }}</div>
<div class="undo-stack">
<div
v-for="operation in undo.filter((v) => v.undoUntil > now)"
:key="operation.operationId"
class="undo-toast"
>
<span>삭제됨</span
><button @click="restore(operation)"><RotateCcw :size="16" />실행 취소</button>
</div>
</div>
</template>

942
apps/web/src/App.vue Normal file
View File

@@ -0,0 +1,942 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import {
ArrowUpRight,
ArrowRight,
Plus,
Monitor,
Smartphone,
Laptop,
QrCode,
Link,
Check,
X,
Copy,
Settings,
Sun,
Moon,
Radio,
Send,
Inbox,
ShieldCheck,
ChevronDown,
LoaderCircle,
RefreshCw,
ArrowDownLeft,
CircleHelp,
File as FileIcon,
Trash2,
Download,
RotateCcw,
EllipsisVertical,
} from '@lucide/vue';
import QRCode from 'qrcode';
import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue';
import { createDeliveryReceiver } from './deliveryReceiver';
import { initialTheme, applyTheme as setTheme } from './theme';
import {
device,
account,
connected,
peers,
requests,
items,
spaces,
error,
bootstrap,
api,
refresh,
send,
showError,
requestAction,
patchDevice,
markRead,
uploadProgress,
sendFiles,
cancelTransfer,
download,
} from './client';
const tab = ref('devices'),
selected = ref(null),
loading = ref(true),
busy = ref(false),
editName = ref(false),
nameDraft = ref(''),
notice = ref(''),
modal = ref(null),
joinCode = ref(''),
approvalRequired = ref(false),
invite = ref(null),
qr = ref(''),
now = ref(Date.now());
const localOnlyQr = /^(localhost|127(?:\.\d+){3}|\[::1\])$/.test(location.hostname);
const theme = ref(initialTheme());
const media = matchMedia('(prefers-color-scheme: dark)');
function applyTheme() {
setTheme(theme.value);
}
function toggleTheme() {
theme.value = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
applyTheme();
}
const incoming = computed(() =>
requests.value.filter(
(r) => r.direction === 'received' && r.status === 'pending' && r.expiresAt > now.value,
),
);
const receiving = computed(() =>
requests.value.filter(
(r) =>
r.direction === 'received' &&
r.status === 'accepted' &&
r.transferStatus !== 'stored' &&
r.transferStatus !== 'failed',
),
);
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
const blockedLinks = receiver.blockedLinks;
watch([requests, items], ([currentRequests, currentItems]) => {
receiver.reconcile(currentRequests, currentItems);
});
onUnmounted(receiver.dispose);
async function receiveAction(r, action) {
if (busy.value) return;
if (action === 'accept') receiver.reserve(r);
await act(async () => {
try {
await requestAction(r.id, action);
} catch (error) {
if (action === 'accept') receiver.abort(r.id);
throw error;
}
});
}
const deviceMenu = ref(null);
function isHost(id) {
return Boolean(id && spaces.value.some((s) => s.creatorDeviceId === id));
}
function managedSpaces(id) {
return spaces.value.filter(
(s) => s.creatorDeviceId === device.value?.id && s.members.some((m) => m.device.id === id),
);
}
const connectionCards = computed(() => {
const cards = new Map(peers.value.map((peer) => [peer.id, { peer, pending: false }]));
for (const space of spaces.value) {
if (space.creatorDeviceId !== device.value?.id) continue;
for (const member of space.members) {
if (member.status === 'pending' && !cards.has(member.device.id))
cards.set(member.device.id, {
peer: { ...member.device, sources: ['space'] },
pending: true,
});
}
}
return [...cards.values()];
});
async function toggleDeviceMenu(id) {
if (deviceMenu.value === id) {
deviceMenu.value = null;
return;
}
deviceMenu.value = id;
await nextTick();
document.getElementById(`device-menu-${id}`)?.querySelector('button')?.focus();
}
function closeDeviceMenu(restore = false) {
const id = deviceMenu.value;
deviceMenu.value = null;
if (restore && id) document.getElementById(`device-menu-toggle-${id}`)?.focus();
}
function outsideDeviceMenu(event) {
if (!event.target.closest('[data-device-menu]')) closeDeviceMenu();
}
function escapeDeviceMenu(event) {
if (event.key === 'Escape' && deviceMenu.value) {
event.preventDefault();
closeDeviceMenu(true);
}
}
document.addEventListener('pointerdown', outsideDeviceMenu);
document.addEventListener('keydown', escapeDeviceMenu);
onUnmounted(() => {
document.removeEventListener('pointerdown', outsideDeviceMenu);
document.removeEventListener('keydown', escapeDeviceMenu);
});
const active = computed(() =>
requests.value.filter(
(r) =>
r.direction === 'sent' &&
(r.status === 'pending' ||
(r.status === 'accepted' && ['waiting', 'uploading'].includes(r.transferStatus))),
),
);
const currentTransfer = computed(() =>
active.value.find((r) => r.recipientDevice?.id === selected.value?.id),
);
async function cancelCurrentTransfer() {
const request = currentTransfer.value;
if (!request) return;
await act(() => cancelTransfer(request.id));
}
const visibleUploadProgress = computed(() =>
Object.fromEntries(
Object.entries(uploadProgress.value).filter(
([, p]) => !p.accountId || p.accountId === account.value?.id,
),
),
);
const canInvite = computed(
() =>
!spaces.value.length ||
(invite.value?.space.id === spaces.value[0]?.id && invite.value.space.expiresAt > now.value),
);
const sendOpen = ref(false),
sendDialog = ref(null);
let beforeSendFocus;
watch(sendOpen, async (open) => {
if (open) {
beforeSendFocus = document.activeElement;
await nextTick();
sendDialog.value?.querySelector('button:not(:disabled)')?.focus();
} else if (beforeSendFocus?.isConnected) beforeSendFocus.focus();
});
function trapSendFocus(event) {
const buttons = [
...sendDialog.value.querySelectorAll(
'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), a[href]',
),
].filter((node) => node.getClientRects().length);
const first = buttons[0],
last = buttons.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
async function closeSend() {
if (busy.value) return;
const request = currentTransfer.value;
if (request)
await act(async () => {
await cancelTransfer(request.id);
sendOpen.value = false;
selected.value = null;
itemToSend.value = null;
});
else {
sendOpen.value = false;
selected.value = null;
itemToSend.value = null;
}
}
watch(requests, (current, previous) => {
for (const request of current)
if (
request.direction === 'sent' &&
request.transferStatus === 'stored' &&
previous.find((old) => old.id === request.id)?.transferStatus !== 'stored' &&
request.recipientDevice?.id === selected.value?.id
) {
sendOpen.value = false;
selected.value = null;
itemToSend.value = null;
notify('전송했습니다.');
}
});
const selectionEpoch = ref(0);
watch(spaces, (current, previous) => {
const hosted = current.find((space) => space.creatorDeviceId === device.value?.id);
const old = previous.find((space) => space.id === hosted?.id);
if (
hosted &&
old &&
hosted.members.some(
(member) =>
member.device.id !== device.value?.id &&
!old.members.some((before) => before.device.id === member.device.id),
)
) {
if (modal.value === 'connect') modal.value = null;
notify('기기가 방에 참여했습니다.');
}
if (!current.length && previous.length) {
selected.value = null;
invite.value = null;
modal.value = null;
notify('방에서 나왔습니다.');
}
});
const vaultItems = ref([]),
vaultCursor = ref(null),
forwardedItem = ref(null),
itemToSend = ref(null);
async function loadVaultChoices() {
if (!account.value) return;
const owner = account.value.id;
await act(async () => {
const result = await api('/vault/contents');
if (account.value?.id !== owner) return;
vaultItems.value = result.items;
vaultCursor.value = result.nextCursor;
});
}
async function moreVault() {
const owner = account.value?.id;
await act(async () => {
const result = await api(`/vault/contents?cursor=${encodeURIComponent(vaultCursor.value)}`);
if (account.value?.id !== owner) return;
vaultItems.value.push(...result.items);
vaultCursor.value = result.nextCursor;
});
}
function prepareVault(item) {
itemToSend.value = item;
selected.value = null;
sendOpen.value = false;
forwardedItem.value = null;
tab.value = 'devices';
notify('받을 기기를 선택해 주세요.');
}
async function forwardVault(item) {
if (!selected.value) {
itemToSend.value = item;
tab.value = 'devices';
notify('받을 기기를 선택해 주세요.');
return;
}
const owner = account.value?.id;
await act(async () => {
await api(
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: selected.value.id },
crypto.randomUUID(),
);
if (account.value?.id !== owner) return;
forwardedItem.value = item;
sendOpen.value = true;
itemToSend.value = null;
await refresh();
});
}
watch(
() => account.value?.id,
() => {
vaultItems.value = [];
vaultCursor.value = null;
itemToSend.value = null;
forwardedItem.value = null;
},
);
let timer;
onMounted(async () => {
applyTheme();
media.addEventListener('change', applyTheme);
timer = setInterval(() => {
now.value = Date.now();
}, 1000);
try {
await bootstrap();
if (/(?:^|&)(verify|reset-password|verify-email-change)=/.test(location.hash.slice(1)))
tab.value = 'vault';
const token = new URLSearchParams(location.hash.slice(1)).get('join');
if (token) {
history.replaceState(null, '', location.pathname);
await api('/spaces/join', 'POST', { token });
await refresh();
notify('연결 공간에 참여했습니다.');
}
} catch (e) {
showError(e);
} finally {
loading.value = false;
}
});
const onFocus = () => void refresh().catch(showError);
const onVisibility = () => {
if (document.visibilityState === 'visible') onFocus();
};
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVisibility);
onUnmounted(() => {
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisibility);
clearInterval(timer);
media.removeEventListener('change', applyTheme);
});
function notify(message) {
notice.value = message;
setTimeout(() => {
if (notice.value === message) notice.value = '';
}, 4000);
}
async function act(fn) {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
await fn();
} catch (e) {
showError(e);
} finally {
busy.value = false;
}
}
async function openDeviceSettings() {
nameDraft.value = device.value?.name ?? '';
modal.value = 'device';
await nextTick();
document.querySelector('.device-settings-form input')?.focus();
}
async function saveName() {
await act(async () => {
await patchDevice({ name: nameDraft.value });
editName.value = false;
modal.value = null;
notify('기기 이름을 변경했습니다.');
});
}
function selectPeer(peer) {
closeDeviceMenu();
selected.value = peer;
sendOpen.value = true;
selectionEpoch.value++;
forwardedItem.value = null;
}
async function submitLinks(urls) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
forwardedItem.value = null;
itemToSend.value = null;
await send(selected.value.id, 'url', { urls });
});
}
async function submitFiles(files) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
forwardedItem.value = null;
itemToSend.value = null;
await sendFiles(selected.value.id, files);
});
}
async function createSpace() {
await act(async () => {
invite.value = await api('/spaces', 'POST', { approvalRequired: approvalRequired.value });
qr.value = await QRCode.toDataURL(`${location.origin}/#join=${invite.value.token}`, {
width: 208,
margin: 1,
color: { dark: '#111111', light: '#ffffff' },
errorCorrectionLevel: 'M',
});
await refresh();
});
}
async function joinSpace() {
await act(async () => {
await api('/spaces/join', 'POST', { code: joinCode.value.replace(/[\s-]/g, '').toUpperCase() });
joinCode.value = '';
await refresh();
modal.value = null;
notify('방에 참여했습니다.');
});
}
async function copyValue(value, item) {
await act(async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
throw Error('복사하지 못했습니다. 내용을 선택해 직접 복사해 주세요.');
}
if (item) await markRead(item);
notify('복사했습니다.');
});
}
function iconFor(name) {
return /phone|android|galaxy/i.test(name)
? Smartphone
: /mac|laptop/i.test(name)
? Laptop
: Monitor;
}
function time(value) {
return new Intl.DateTimeFormat('ko', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(value);
}
function size(bytes) {
return bytes >= 1073741824
? (bytes / 1073741824).toFixed(1) + ' GiB'
: bytes >= 1048576
? (bytes / 1048576).toFixed(1) + ' MiB'
: bytes >= 1024
? (bytes / 1024).toFixed(1) + ' KiB'
: bytes + ' B';
}
async function spaceAction(space, action, memberId) {
await act(async () => {
await api(
`/spaces/${space.id}${memberId ? `/members/${memberId}${action === 'approve' ? '/approve' : ''}` : ''}`,
action === 'approve' ? 'POST' : 'DELETE',
action === 'approve' ? {} : undefined,
);
if (invite.value?.space.id === space.id && !memberId) invite.value = null;
await refresh();
closeDeviceMenu();
if (memberId && action === 'remove') {
if (selected.value?.id === memberId && !peers.value.some((p) => p.id === memberId))
selected.value = null;
notify('연결 공간에서 내보냈습니다.');
}
});
}
</script>
<template>
<div class="app-shell" :data-online="connected">
<header class="site-header">
<div class="header-brand">
<button class="wordmark" @click="tab = 'devices'" aria-label="hop. 홈">
hop<span>.</span>
</button>
<span class="header-tagline">기기 사이를 가볍게.</span>
</div>
<div class="header-actions">
<button
class="icon-button theme-toggle"
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
@click="toggleTheme"
>
<Moon v-if="theme === 'light'" :size="19" /><Sun v-else :size="19" /></button
><button class="login-button" @click="tab = 'vault'">
{{ account ? '보관함' : '로그인' }} <ArrowUpRight :size="15" />
</button>
</div>
</header>
<main>
<p v-if="!connected && !loading" class="reconnect-note" role="status">
연결을 복구하고 있어요.
</p>
<div v-if="loading" class="loading-state">
<LoaderCircle class="spin" :size="24" />
<p>기기를 연결하고 있어요.</p>
</div>
<template v-else>
<Teleport to="body">
<section
v-if="receiving.length || blockedLinks.length"
class="incoming-section"
aria-label="수신 알림"
aria-live="polite"
>
<article
v-for="r in receiving"
:key="`receiving-${r.id}`"
class="incoming-card receiving-card"
>
<span class="incoming-icon"><LoaderCircle class="spin" :size="20" /></span>
<div class="incoming-info">
<strong>{{ r.senderDevice?.name }}에서 받는 중</strong>
<p>
{{
r.transferStatus === 'uploading'
? '파일을 업로드하고 있어요.'
: '수락했습니다. 발신 기기에서 전달을 준비하고 있어요.'
}}
</p>
</div>
</article>
<article
v-for="item in blockedLinks"
:key="item.accessId"
class="incoming-card delivery-blocked"
>
<div class="incoming-info">
<strong>브라우저가 새 탭 열기를 제한했어요.</strong>
<p>아래 링크를 누르면 바로 열립니다.</p>
</div>
<a
class="button primary small"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
@click="receiver.opened(item)"
>링크 열기 <ArrowUpRight :size="15"
/></a>
<button
class="icon-button"
aria-label="안내 닫기"
@click="blockedLinks = blockedLinks.filter((i) => i.accessId !== item.accessId)"
>
<X :size="16" />
</button>
</article>
</section>
<div v-if="incoming.length" class="receive-backdrop">
<article
v-for="r in incoming.slice(0, 1)"
:key="r.id"
class="incoming-card consent-card"
role="dialog"
aria-modal="true"
aria-label="전송 수락 요청"
tabindex="-1"
>
<div class="incoming-icon"><ArrowDownLeft :size="22" /></div>
<div class="incoming-info">
<strong
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
>
<p>
{{ r.kind === 'file' ? '파일' : '링크' }} · {{ r.count }}개 ·
{{ r.byteSize.toLocaleString() }}바이트
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
</p>
</div>
<ul v-if="r.files" class="request-files">
<li v-for="file in r.files" :key="file.id">
{{ file.name }} · {{ size(file.size) }}
</li>
</ul>
<div class="incoming-actions">
<button
class="button primary small"
:disabled="busy"
@click="receiveAction(r, 'accept')"
>
<Check :size="16" /> 수락</button
><button class="button small" :disabled="busy" @click="receiveAction(r, 'reject')">
거절</button
><button
class="text-button muted"
:disabled="busy"
@click="receiveAction(r, 'block')"
>
차단
</button>
</div>
<p class="request-note">기기 이름은 신원이 인증된 표시가 아닙니다.</p>
</article>
</div>
</Teleport>
<template v-if="tab === 'devices'">
<section class="devices-stage">
<div class="stage-heading"><h1>어디로 보낼까요?</h1></div>
<section class="nearby-section">
<div class="section-heading">
<div>
<h2>
{{ spaces.length ? '방 안의 기기' : '주변 기기' }}
<span>{{ connectionCards.length.toString().padStart(2, '0') }}</span>
</h2>
</div>
<button class="icon-button" @click="act(refresh)" aria-label="기기 목록 새로고침">
<RefreshCw :size="17" />
</button>
</div>
<p v-if="!connectionCards.length" class="empty-device-hint">
{{
device?.discoverable
? '상대도 hop.을 열거나 QR로 연결해 주세요.'
: '아래 내 기기 설정에서 발견 허용을 켜 주세요.'
}}
</p>
<div
class="peer-grid"
:class="`device-count-${connectionCards.length + (canInvite ? 1 : 0)}`"
v-if="connectionCards.length || canInvite"
>
<div
v-for="card in connectionCards"
:key="card.peer.id"
class="peer-entry"
data-device-menu
>
<button
class="peer-card"
:class="{ selected: selected?.id === card.peer.id, pending: card.pending }"
:disabled="card.pending"
@click="selectPeer(card.peer)"
>
<span class="peer-top"
><component :is="iconFor(card.peer.name)" :size="30" /><span
class="peer-live"
:class="{ hasMenu: managedSpaces(card.peer.id).length }"
><template v-if="card.pending">승인 대기</template
><template v-else><i></i>온라인</template></span
></span
><strong>{{ card.peer.name }}</strong
><span class="peer-code">#{{ card.peer.code }}</span
><span class="peer-bottom"
><span
>{{ card.peer.sources.includes('network') ? '같은 네트워크' : '연결 공간' }}
<span v-if="isHost(card.peer.id)" class="host-badge">HOST</span></span
><span v-if="!card.pending" class="peer-select"
><Check v-if="selected?.id === card.peer.id" :size="14" /><ArrowUpRight
v-else
:size="14" /></span
></span></button
><button
v-if="managedSpaces(card.peer.id).length"
:id="`device-menu-toggle-${card.peer.id}`"
class="icon-button device-menu-toggle"
:aria-label="`${card.peer.name} 기기 더보기`"
aria-haspopup="menu"
:aria-expanded="deviceMenu === card.peer.id"
:aria-controls="`device-menu-${card.peer.id}`"
@click="toggleDeviceMenu(card.peer.id)"
>
<EllipsisVertical :size="18" />
</button>
<div
v-if="deviceMenu === card.peer.id"
:id="`device-menu-${card.peer.id}`"
class="device-menu"
role="menu"
:aria-label="`${card.peer.name} 기기 관리`"
>
<template v-for="space in managedSpaces(card.peer.id)" :key="space.id"
><button
v-if="
space.members.some(
(m) => m.device.id === card.peer.id && m.status === 'pending',
)
"
role="menuitem"
:disabled="busy"
@click="spaceAction(space, 'approve', card.peer.id)"
>
참여 승인</button
><button
role="menuitem"
class="kick-action"
:disabled="busy"
@click="spaceAction(space, 'remove', card.peer.id)"
>
내보내기<span v-if="managedSpaces(card.peer.id).length > 1">
· 연결 공간 {{ spaces.findIndex((s) => s.id === space.id) + 1 }}</span
>
</button></template
>
</div>
</div>
<button
v-if="canInvite"
class="qr-connect-card"
:aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'"
@click="modal = 'connect'"
>
<QrCode :size="32" /><strong>{{
spaces.length ? 'QR 초대 보기' : 'QR로 연결'
}}</strong
><span>다른 기기 연결하기</span>
</button>
</div>
<div v-else class="empty-peers">
<div class="orbit-icon"><Radio :size="35" stroke-width="1.2" /></div>
<h3>
{{ spaces.length ? '기기 참여를 기다리고 있어요.' : '상대도 hop.을 열어주세요.' }}
</h3>
<p v-if="!spaces.length">다른 네트워크라면 QR이나 코드로 연결하세요.</p>
<button
v-if="
!spaces.length ||
(invite?.space.id === spaces[0]?.id && invite.space.expiresAt > now)
"
class="button room-connect"
@click="modal = 'connect'"
>
<QrCode :size="17" />{{ spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결' }}
</button>
</div>
</section>
<div v-if="spaces.length" class="connection-actions">
<button
class="text-button room-leave"
:disabled="busy"
@click="
spaceAction(
spaces[0],
'remove',
spaces[0].creatorDeviceId === device?.id ? undefined : device?.id,
)
"
>
방 나가기
</button>
</div>
</section>
</template>
<template v-else
><AccountPanel
:initial-view="tab === 'received' ? 'inbox' : 'vault'"
@devices="tab = 'devices'"
@send-item="prepareVault"
/></template>
</template>
</main>
<footer class="site-footer device-footer">
<div class="my-device-info" v-if="device">
<button
class="device-name footer-device-summary"
aria-label="내 기기 설정"
@click="openDeviceSettings"
>
<i class="device-state-dot" :class="{ enabled: device.discoverable }"></i
><span class="footer-device-name">{{ device.name }}</span
><small>· {{ device.discoverable ? '발견 허용' : '발견 꺼짐' }}</small
><span class="footer-device-code">#{{ device.code }}</span
><span v-if="isHost(device.id)" class="host-badge">HOST</span><Settings :size="14" />
</button>
</div>
<span class="footer-brand">by noname.company</span>
</footer>
<Teleport to="body"
><div
v-if="sendOpen && selected"
class="modal-backdrop send-backdrop"
@click.self="closeSend"
@keydown.esc.stop.prevent="closeSend"
>
<section
ref="sendDialog"
class="modal send-dialog"
role="dialog"
aria-modal="true"
:aria-label="`${selected.name}에 보내기`"
@keydown.tab="trapSendFocus"
>
<header>
<h2>{{ selected.name }}에 보내기</h2>
<button
class="icon-button"
:disabled="busy"
@click="closeSend"
aria-label="전송 화면 닫기"
>
<X :size="20" />
</button>
</header>
<SendComposer
:target-name="selected?.name"
:target-key="selected?.id"
:clipboard-key="selectionEpoch"
:read-clipboard-on-selection="true"
:busy="busy"
:transfer="currentTransfer"
:progress="
Object.entries(visibleUploadProgress)
.filter(([key]) => key.startsWith(currentTransfer?.id + ':'))
.map(([, value]) => value)
"
:disabled="!connected"
@cancel="cancelCurrentTransfer"
@files="submitFiles"
@links="submitLinks"
:transfer-source="forwardedItem"
:initial-item="itemToSend"
:vault-enabled="Boolean(account)"
:vault-owner="account?.id"
:vault-items="vaultItems"
:vault-cursor="vaultCursor"
@vault-open="loadVaultChoices"
@vault-more="moreVault"
@vault="forwardVault"
>
</SendComposer>
</section></div
></Teleport>
<div v-if="notice" class="toast" role="status"><Check :size="17" />{{ notice }}</div>
<div v-if="error" class="error-toast" role="alert">
<span>{{ error }}</span
><button class="icon-button" @click="error = ''" aria-label="오류 닫기">
<X :size="17" />
</button>
</div>
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
<section
class="modal"
role="dialog"
aria-modal="true"
:aria-label="modal === 'connect' ? 'QR·코드 연결' : '설정'"
>
<header>
<h2>{{ modal === 'connect' ? 'QR · 코드로 연결' : '내 기기 설정' }}</h2>
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
<X :size="20" />
</button>
</header>
<template v-if="modal === 'connect'"
><p class="modal-subtitle">
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
</p>
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
<p v-if="localOnlyQr" class="local-qr-note">
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의
다른 브라우저에서 아래 코드를 입력해 주세요.
</p>
<div class="invite-code">{{ invite.code.slice(0, 4) }} {{ invite.code.slice(4) }}</div>
<button class="text-button" @click="copyValue(invite.code)">
<Copy :size="14" />코드 복사
</button>
<p>
{{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 · 최대
10대
</p>
</div>
<div v-else-if="!spaces.length" class="create-space">
<label class="checkbox-label"
><input type="checkbox" v-model="approvalRequired" />참여할 때 내 승인 받기</label
><button class="button primary" @click="createSpace" :disabled="busy">
<Plus :size="18" />연결 공간 만들기
</button>
</div>
<div v-if="!spaces.length" class="modal-divider"><span>또는 코드로 참여</span></div>
<form v-if="!spaces.length" @submit.prevent="joinSpace" class="join-form">
<input
v-model="joinCode"
aria-label="연결 코드"
placeholder="8자리 연결 코드"
maxlength="10"
autocomplete="off"
autocapitalize="characters"
/><button
class="button"
type="submit"
:disabled="busy || joinCode.replace(/[\s-]/g, '').length !== 8"
>
참여 <ArrowRight :size="16" />
</button></form
></template>
<template v-else
><form class="name-form device-settings-form" @submit.prevent="saveName">
<label
>기기 이름<input
v-model="nameDraft"
aria-label="기기 이름"
maxlength="40"
required /></label
><button class="button primary" :disabled="busy" aria-label="이름 저장">
이름 저장
</button>
</form>
<label class="settings-row discovery-setting"
><span>다른 기기에서 발견 허용</span
><input
type="checkbox"
:checked="device?.discoverable"
:disabled="busy"
@change="act(() => patchDevice({ discoverable: $event.target.checked }))" /></label
></template>
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,426 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue';
import {
FileUp,
File as FileIcon,
Link,
Send,
X,
ClipboardPaste,
Inbox,
Check,
ArrowUpRight,
} from '@lucide/vue';
import { parseUrls } from '@hop/shared';
const props = defineProps({
targetName: String,
targetKey: String,
clipboardKey: Number,
busy: Boolean,
disabled: Boolean,
transfer: Object,
progress: Array,
readClipboardOnSelection: Boolean,
saveMode: Boolean,
contentMode: String,
transferSource: Object,
vaultEnabled: Boolean,
vaultItems: Array,
vaultCursor: String,
initialItem: Object,
vaultOwner: String,
});
const emit = defineEmits(['files', 'links', 'vault', 'vault-open', 'vault-more', 'cancel']);
const source = ref('file'),
files = ref([]),
urlInput = ref(''),
clipboardManaged = ref(false),
reading = ref(false),
feedback = ref(''),
selectedVault = ref(null),
fileInput = ref(null);
let generation = 0;
const parsed = computed(() => parseUrls(urlInput.value));
const validUrl = computed(
() =>
parsed.value.urls.length > 0 && parsed.value.urls.length <= 20 && !parsed.value.invalid.length,
);
const mutable = computed(() => !props.busy && !props.disabled && !props.transfer);
const ready = computed(() =>
source.value === 'url'
? validUrl.value && !reading.value
: source.value === 'file'
? files.value.length > 0
: Boolean(selectedVault.value),
);
async function loadClipboard(force = false) {
if (
!props.targetKey ||
!props.readClipboardOnSelection ||
props.contentMode === 'file' ||
!mutable.value ||
document.visibilityState === 'hidden'
)
return;
if (
!force &&
(source.value === 'vault' || files.value.length || (urlInput.value && !clipboardManaged.value))
)
return;
const current = ++generation;
reading.value = true;
feedback.value = '';
try {
if (!navigator.clipboard?.readText) throw Error('Unavailable');
const result = parseUrls(await navigator.clipboard.readText());
if (current !== generation) return;
if (result.urls.length && result.urls.length <= 20 && !result.invalid.length) {
source.value = 'url';
files.value = [];
selectedVault.value = null;
urlInput.value = result.urls.join('\n');
clipboardManaged.value = true;
} else if (force || clipboardManaged.value) {
urlInput.value = '';
clipboardManaged.value = false;
if (source.value === 'url') feedback.value = '클립보드에 링크가 없어요. 직접 입력해 주세요.';
}
} catch {
if (current === generation && source.value === 'url')
feedback.value = '링크를 직접 붙여넣어 주세요.';
} finally {
if (current === generation) reading.value = false;
}
}
watch(
() => [props.targetKey, props.contentMode, props.clipboardKey],
(current, previous) => {
if (current[0] !== previous?.[0] || current[1] !== previous?.[1]) {
generation++;
reading.value = false;
files.value = [];
urlInput.value = '';
selectedVault.value = null;
clipboardManaged.value = false;
feedback.value = '';
source.value = props.contentMode === 'url' ? 'url' : 'file';
}
if (props.initialItem) {
source.value = 'vault';
selectedVault.value = props.initialItem;
} else void loadClipboard(true);
},
{ immediate: true },
);
watch(
() => props.transferSource,
(item) => {
if (item && props.transfer) {
source.value = 'vault';
selectedVault.value = item;
files.value = [];
urlInput.value = '';
}
},
);
watch(
() => props.transfer,
(transfer) => {
if (transfer && props.transferSource) {
source.value = 'vault';
selectedVault.value = props.transferSource;
}
},
);
watch(
() => props.vaultEnabled,
(enabled) => {
if (!enabled && source.value === 'vault') {
source.value = 'file';
selectedVault.value = null;
}
},
);
watch(
() => props.vaultItems,
(items) => {
if (
selectedVault.value &&
!props.initialItem &&
!props.transfer &&
!items?.some((item) => item.accessId === selectedVault.value.accessId)
)
selectedVault.value = null;
},
);
watch(
() => props.vaultOwner,
(owner, previous) => {
if (owner !== previous && source.value === 'vault') {
selectedVault.value = null;
source.value = 'file';
}
},
);
watch(
() => props.disabled,
(disabled) => {
if (!disabled) void loadClipboard();
},
);
const focus = () => void loadClipboard();
const visible = () => {
if (document.visibilityState === 'visible') focus();
};
window.addEventListener('focus', focus);
document.addEventListener('visibilitychange', visible);
onUnmounted(() => {
generation++;
window.removeEventListener('focus', focus);
document.removeEventListener('visibilitychange', visible);
});
function editUrl() {
generation++;
reading.value = false;
clipboardManaged.value = false;
feedback.value = '';
}
function choose(list) {
if (!mutable.value || props.contentMode === 'url' || !list?.length) return;
generation++;
reading.value = false;
files.value = Array.from(list);
source.value = 'file';
urlInput.value = '';
selectedVault.value = null;
clipboardManaged.value = false;
feedback.value = '';
}
function pickFiles() {
if (mutable.value && fileInput.value) {
fileInput.value.value = '';
fileInput.value.click();
}
}
function toLink() {
if (!mutable.value) return;
generation++;
reading.value = false;
files.value = [];
selectedVault.value = null;
urlInput.value = '';
source.value = 'url';
clipboardManaged.value = false;
void loadClipboard(true);
}
function toVault() {
if (!mutable.value || !props.vaultEnabled) return;
generation++;
reading.value = false;
files.value = [];
urlInput.value = '';
selectedVault.value = null;
source.value = 'vault';
emit('vault-open');
}
function selectVault(item) {
if (mutable.value) selectedVault.value = item;
}
function pasted(event) {
if (!mutable.value) return;
if (event.clipboardData?.files.length) {
event.preventDefault();
choose(event.clipboardData.files);
return;
}
if (props.contentMode === 'file') return;
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
if (result.urls.length && !result.invalid.length) {
event.preventDefault();
editUrl();
files.value = [];
selectedVault.value = null;
source.value = 'url';
urlInput.value = result.urls.join('\n');
}
}
function submit() {
if (props.transfer) {
emit('cancel');
return;
}
if (!ready.value || !mutable.value) return;
if (source.value === 'url') emit('links', parsed.value.urls);
else if (source.value === 'file') emit('files', [...files.value]);
else emit('vault', selectedVault.value);
}
function size(value) {
return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
: value >= 1024
? (value / 1024).toFixed(1) + ' KiB'
: value + ' B';
}
</script>
<template>
<section
class="unified-composer single-composer"
@paste="pasted"
@dragover.prevent
@drop.stop.prevent="choose($event.dataTransfer?.files)"
>
<input
v-if="contentMode !== 'url'"
ref="fileInput"
type="file"
multiple
class="sr-only"
aria-label="보낼 파일 선택"
:disabled="!mutable"
@change="choose($event.target.files)"
/>
<div
class="content-pane"
@dragover.prevent
@drop.stop.prevent="choose($event.dataTransfer?.files)"
>
<template v-if="source === 'url'">
<div class="content-heading">
<strong><Link :size="18" />보낼 링크</strong
><button
class="text-button clipboard-load"
:disabled="!mutable || reading"
@click="loadClipboard(true)"
>
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '클립보드 가져오기' }}
</button>
</div>
<textarea
v-model="urlInput"
aria-label="보낼 URL"
placeholder="https://example.com"
rows="3"
:disabled="!mutable"
@input="editUrl"
class="current-url"
/>
<p v-if="feedback" class="content-feedback" role="status">{{ feedback }}</p>
<p v-else-if="urlInput.trim() && !validUrl" class="content-feedback" role="status">
HTTP 또는 HTTPS 링크를 입력해 주세요.
</p>
</template>
<template v-else-if="source === 'file'">
<div v-if="!files.length" class="current-file-empty">
<FileUp :size="32" /><strong>보낼 파일을 선택하세요.</strong
><span>파일을 여기에 놓거나 클릭해 선택</span
><button class="button" :disabled="!mutable" @click="pickFiles">파일 선택</button>
</div>
<template v-else
><div class="content-heading">
<strong><FileIcon :size="18" />보낼 파일 {{ files.length }}개</strong
><button class="text-button" :disabled="!mutable" @click="pickFiles">다시 선택</button>
</div>
<ul class="selected-files">
<li v-for="(file, index) in files" :key="index">
<FileIcon :size="15" /><span>{{ file.name }}</span
><small>{{ size(file.size) }}</small
><button
class="icon-button"
:disabled="!mutable"
:aria-label="`${file.name} 선택 해제`"
@click="files.splice(index, 1)"
>
<X :size="15" />
</button>
</li></ul
></template>
</template>
<template v-else>
<div class="content-heading">
<strong><Inbox :size="18" />보관함에서 가져오기</strong>
</div>
<div v-if="selectedVault" class="current-vault">
<FileIcon v-if="selectedVault.file" :size="28" /><Link v-else :size="28" /><strong>{{
selectedVault.note || selectedVault.file?.name || selectedVault.url
}}</strong
><small v-if="selectedVault.note">{{
selectedVault.file?.name || selectedVault.url
}}</small
><button
v-if="!transfer"
class="text-button"
:disabled="!mutable"
@click="selectedVault = null"
>
다른 자료 선택
</button>
</div>
<template v-else
><button
v-for="item in vaultItems"
:key="item.accessId"
class="vault-choice"
:class="{ chosen: selectedVault?.accessId === item.accessId }"
:aria-pressed="selectedVault?.accessId === item.accessId"
:disabled="!mutable"
@click="selectVault(item)"
>
<FileIcon v-if="item.file" :size="18" /><Link v-else :size="18" /><span
><strong>{{ item.note || item.file?.name || item.url }}</strong
><small
>{{ item.role === 'self' ? '임시 저장' : '받은 항목' }} ·
{{ item.file ? '파일' : '링크' }}</small
></span
><Check v-if="selectedVault?.accessId === item.accessId" :size="16" />
</button>
<p v-if="!vaultItems?.length" class="content-feedback">
{{ busy ? '보관함을 불러오고 있어요.' : '보관함이 비어 있어요.' }}
</p>
<button
v-if="vaultCursor"
class="text-button"
:disabled="busy"
@click="emit('vault-more')"
>
더 보기
</button></template
>
</template>
<p v-if="transfer" class="composer-transfer-state" role="status">
{{ transfer.status === 'pending' ? '상대 기기의 수락을 기다립니다.' : '전송 중입니다.' }}
</p>
<div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span
>{{ upload.name }} ·
{{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span
><progress :value="upload.progress" max="100" />
</div>
</div>
<button
class="button primary content-submit"
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
:disabled="busy || disabled || (!transfer && !ready)"
@click="submit"
>
{{ transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
}}<X v-if="transfer" :size="16" /><ArrowUpRight v-else :size="16" />
</button>
<div v-if="!contentMode" class="content-switches">
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
<FileIcon :size="14" />대신 파일 선택하기</button
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">
<Link :size="14" />대신 링크 붙여넣기</button
><button
v-if="vaultEnabled && source !== 'vault'"
class="text-button"
:disabled="!mutable"
@click="toVault"
>
<Inbox :size="14" />보관함에서 가져오기
</button>
</div>
<p class="consent-note">
{{ saveMode ? '저장한 자료는 최대 7일 후 삭제됩니다.' : '상대가 수락한 후에만 전달됩니다.' }}
</p>
</section>
</template>

409
apps/web/src/client.js Normal file
View File

@@ -0,0 +1,409 @@
import { createRefreshQueue } from './refreshQueue';
import { ref } from 'vue';
import { io } from 'socket.io-client';
export const account = ref(null),
registrationOpen = ref(false),
passwordMinLength = ref(12);
export async function refreshAccount() {
const result = await api('/auth/me');
account.value = result.account;
registrationOpen.value = result.registrationOpen;
passwordMinLength.value = result.passwordMinLength ?? 12;
}
export function reconnect() {
connect();
}
export const device = ref(null),
csrfToken = ref(''),
sessionExpiresAt = ref(0),
connected = ref(false),
peers = ref([]),
requests = ref([]),
items = ref([]),
spaces = ref([]),
discoveryEnabled = ref(false),
error = ref('');
export const nextCursor = ref(null);
let socket;
let reauthorizing;
const drafts = new Map();
const completing = new Set();
export async function api(path, method = 'GET', body, key) {
const response = await fetch(`/api${path}`, {
method,
credentials: 'same-origin',
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(method !== 'GET' ? { 'X-CSRF-Token': csrfToken.value } : {}),
...(key ? { 'Idempotency-Key': key } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message ?? '요청을 처리하지 못했습니다.');
return payload;
}
export async function bootstrap() {
const init = async () => {
const s = await api('/guest/session', 'POST', {});
device.value = s.device;
csrfToken.value = s.csrfToken;
sessionExpiresAt.value = s.expiresAt;
};
if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init);
else await init();
await refreshAccount();
connect();
await refresh();
startReconciliation();
}
function connect() {
socket?.disconnect();
socket = io({ auth: { csrfToken: csrfToken.value }, reconnection: true });
socket.on('connect', () => {
connected.value = true;
void refresh().catch(showError);
});
socket.on('disconnect', () => {
connected.value = false;
});
socket.on('inbox:updated', () => window.dispatchEvent(new Event('hop-account-updated')));
socket.on('account:revoked', () => {
clearAccountUploads(account.value?.id);
account.value = null;
window.dispatchEvent(new Event('hop-account-revoked'));
});
socket.on('connect_error', (err) => {
connected.value = false;
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
reauthorizing = (async () => {
const session = await api('/guest/session', 'POST', {});
device.value = session.device;
csrfToken.value = session.csrfToken;
sessionExpiresAt.value = session.expiresAt;
if (socket) {
socket.auth = { csrfToken: csrfToken.value };
socket.connect();
}
})()
.catch(showError)
.finally(() => {
reauthorizing = undefined;
});
});
for (const event of [
'device:online',
'device:offline',
'device:updated',
'space:updated',
'request:created',
'request:updated',
'transfer:updated',
'access:updated',
])
socket.on(event, () => void refresh().catch(showError));
}
export function showError(err) {
error.value = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
}
export const refresh = createRefreshQueue(async () => {
const snapshot = await api('/device/snapshot');
device.value = snapshot.device;
peers.value = snapshot.devices;
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
requests.value = snapshot.requests;
items.value = snapshot.items;
nextCursor.value = snapshot.nextCursor;
spaces.value = snapshot.spaces;
for (const req of requests.value) {
if (
req.kind === 'file' &&
req.status === 'accepted' &&
req.transferStatus === 'waiting' &&
fileDrafts.has(req.id) &&
!Object.keys(uploadProgress.value).some(
(k) => k.startsWith(req.id) && uploadProgress.value[k]?.status === 'failed',
)
)
void deliverFiles(req.id);
if (req.status === 'accepted' && req.transferStatus === 'waiting' && drafts.has(req.id))
void deliver(req.id);
if (
['rejected', 'expired', 'cancelled'].includes(req.status) ||
req.transferStatus === 'stored'
)
drafts.delete(req.id);
}
});
export async function send(targetDeviceId, kind, values) {
const content = values.urls.join('\n');
const { requestId } = await api(
'/transfer-requests',
'POST',
{
targetDeviceId,
kind,
byteSize: new TextEncoder().encode(content).length,
count: values.urls.length,
},
crypto.randomUUID(),
);
drafts.set(requestId, { ...values, key: crypto.randomUUID() });
await refresh();
return requestId;
}
async function deliver(id) {
if (completing.has(id)) return;
const draft = drafts.get(id);
if (!draft) return;
completing.add(id);
try {
await api(`/transfers/${id}/complete`, 'POST', { urls: draft.urls }, draft.key);
drafts.delete(id);
await refresh();
return true;
} catch (err) {
showError(err);
return false;
} finally {
completing.delete(id);
}
}
export async function retryDelivery(id, values) {
drafts.set(id, { ...values, key: crypto.randomUUID() });
if (!(await deliver(id))) throw new Error(error.value || '전송을 완료하지 못했습니다.');
}
export async function requestAction(id, action) {
await api(
`/transfer-requests/${id}/${action}`,
'POST',
{},
action === 'accept' ? crypto.randomUUID() : undefined,
);
await refresh();
}
export async function patchDevice(input) {
await api('/device/me', 'PATCH', input);
await refresh();
}
export async function markRead(item, value = true) {
await api(`/access/${item.accessId}/read`, 'PATCH', { read: value });
await refresh();
}
export async function loadMore() {
if (!nextCursor.value) return;
const result = await api(`/device/items?cursor=${encodeURIComponent(nextCursor.value)}`);
items.value.push(...result.items);
nextCursor.value = result.nextCursor;
}
export async function reset() {
const result = await api('/device/reset', 'POST', {});
drafts.clear();
fileDrafts.clear();
for (const [key, xhr] of uploadRequests) if (!uploadProgress.value[key]?.accountId) xhr.abort();
uploadProgress.value = Object.fromEntries(
Object.entries(uploadProgress.value).filter(([, p]) => p.accountId),
);
device.value = result.device;
csrfToken.value = result.csrfToken;
sessionExpiresAt.value = result.expiresAt;
connect();
await refresh();
}
export const uploadProgress = ref({});
const fileDrafts = new Map();
const fileSending = new Set();
const uploadRequests = new Map();
const cancelledTransfers = new Set();
export async function sendFiles(targetDeviceId, files) {
const input = {
targetDeviceId,
kind: 'file',
byteSize: files.reduce((n, f) => n + f.size, 0),
count: files.length,
files: files.map((f) => ({
name: f.name,
size: f.size,
type: f.type || 'application/octet-stream',
})),
};
const { requestId } = await api('/transfer-requests', 'POST', input, crypto.randomUUID());
fileDrafts.set(requestId, files);
await refresh();
return requestId;
}
export function uploadFile(id, index, file, accountId) {
const progressKey = `${id}:${index}`;
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'uploading',
accountId,
};
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
uploadRequests.set(progressKey, xhr);
xhr.open('POST', `/api/uploads?requestId=${id}&fileIndex=${index}`);
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
xhr.setRequestHeader('Idempotency-Key', crypto.randomUUID());
xhr.upload.onprogress = (e) => {
if (e.lengthComputable)
uploadProgress.value[progressKey].progress = Math.min(
100,
Math.round((e.loaded / e.total) * 100),
);
};
const finish = () => uploadRequests.delete(progressKey);
xhr.onload = () => {
finish();
if (accountId && account.value?.id !== accountId) {
delete uploadProgress.value[progressKey];
reject(Error('계정 세션이 종료되었습니다.'));
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
uploadProgress.value[progressKey] = { name: file.name, progress: 100, status: 'stored' };
resolve();
} else {
let message = '업로드하지 못했습니다.';
try {
message = JSON.parse(xhr.responseText).message;
} catch {}
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'failed',
accountId,
message,
};
reject(Error(message));
}
};
xhr.onerror = xhr.onabort = () => {
finish();
if (accountId && account.value?.id !== accountId) {
delete uploadProgress.value[progressKey];
reject(Error('계정 세션이 종료되었습니다.'));
return;
}
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'failed',
accountId,
message: '업로드가 중단되었습니다.',
};
reject(Error('업로드가 중단되었습니다.'));
};
const form = new FormData();
form.append('file', file);
xhr.send(form);
});
}
async function deliverFiles(id) {
if (fileSending.has(id)) return;
const files = fileDrafts.get(id),
request = requests.value.find((r) => r.id === id);
if (!files || !request) return;
fileSending.add(id);
try {
for (const [index, file] of files.entries()) {
if (cancelledTransfers.has(id)) break;
if (request.files?.[index]?.status === 'stored') continue;
try {
await uploadFile(id, index, file);
} catch (err) {
if (!cancelledTransfers.has(id)) showError(err);
}
if (requests.value.find((r) => r.id === id)?.status !== 'accepted') break;
}
await refresh();
if (requests.value.find((r) => r.id === id)?.transferStatus === 'stored') fileDrafts.delete(id);
} finally {
fileSending.delete(id);
}
}
export async function retryFiles(id, files) {
const r = requests.value.find((r) => r.id === id);
if (
!r?.files ||
r.files.length !== files.length ||
files.some((f, i) => f.name !== r.files[i].name || f.size !== r.files[i].size)
)
throw Error('수락한 파일 목록과 이름·크기가 같은 파일을 선택해 주세요.');
fileDrafts.set(id, files);
await deliverFiles(id);
}
export function cancelUpload(key) {
uploadRequests.get(key)?.abort();
}
export async function cancelTransfer(id) {
// Cancel on the server before aborting uploads so a failed upload cannot restart delivery.
cancelledTransfers.add(id);
try {
await api(`/transfer-requests/${id}/cancel`, 'POST', {});
} catch (error) {
cancelledTransfers.delete(id);
throw error;
}
drafts.delete(id);
fileDrafts.delete(id);
for (const key of uploadRequests.keys()) {
if (key.startsWith(`${id}:`)) uploadRequests.get(key)?.abort();
}
for (const key of Object.keys(uploadProgress.value)) {
if (key.startsWith(`${id}:`)) delete uploadProgress.value[key];
}
await refresh();
}
export async function deleteItems(ids) {
const original = [...items.value];
items.value = items.value.filter((i) => !ids.includes(i.accessId));
try {
const result = await api('/access/delete-batch', 'POST', { ids });
await refresh();
return result;
} catch (err) {
items.value = original;
throw err;
}
}
export async function undoDelete(id) {
await api(`/deletion-operations/${id}/undo`, 'POST', {});
await refresh();
}
export async function download(item) {
const a = document.createElement('a');
a.href = `/api/access/${item.accessId}/download`;
a.download = item.file?.name ?? '';
document.body.append(a);
a.click();
a.remove();
await markRead(item);
}
export function clearAccountUploads(accountId) {
for (const [key, progress] of Object.entries(uploadProgress.value)) {
if (!progress.accountId || (accountId && progress.accountId !== accountId)) continue;
uploadRequests.get(key)?.abort();
delete uploadProgress.value[key];
}
}
let reconciliation;
function startReconciliation() {
if (reconciliation) return;
reconciliation = setInterval(() => {
if (
document.visibilityState !== 'visible' ||
!requests.value.some(
(r) => r.status === 'pending' || (r.status === 'accepted' && r.transferStatus !== 'stored'),
)
)
return;
void api('/transfer-requests')
.then((result) => {
const summary = (rs) => JSON.stringify(rs.map((r) => [r.id, r.status, r.transferStatus]));
if (summary(result.requests) !== summary(requests.value)) return refresh();
})
.catch(showError);
}, 5000);
}

View File

@@ -0,0 +1,83 @@
import { ref } from 'vue';
// Reserve tabs during the acceptance click; later socket updates have no user activation.
// Only the tab that accepts a request performs its delivery actions.
export function createDeliveryReceiver({ download, markRead, onError }) {
const pending = new Map();
const blockedLinks = ref([]);
function reserve(request) {
if (pending.has(request.id)) return;
const tabs = [];
if (request.kind === 'url') {
for (let i = 0; i < request.count; i++) {
let tab = null;
try {
tab = window.open('about:blank', '_blank');
} catch {
/* Browser restriction. */
}
if (tab) {
tab.opener = null;
tab.document.title = 'hop. 링크를 받는 중';
tab.document.body.textContent = '수락한 링크를 받고 있어요. 잠시만 기다려 주세요.';
}
tabs.push(tab);
}
}
pending.set(request.id, { tabs, handled: new Set() });
}
function abort(id) {
const delivery = pending.get(id);
for (const tab of delivery?.tabs ?? []) tab?.close();
pending.delete(id);
}
function reconcile(requests, items) {
for (const [id, delivery] of pending) {
const request = requests.find((r) => r.id === id);
if (!request) continue;
if (
['rejected', 'cancelled', 'expired', 'blocked'].includes(request.status) ||
(request.status === 'pending' && request.expiresAt <= Date.now())
) {
abort(id);
continue;
}
for (const item of items.filter((i) => i.batchId === request.batchId)) {
if (delivery.handled.has(item.accessId)) continue;
delivery.handled.add(item.accessId);
if (item.file) {
void download(item).catch(onError);
} else if (item.url) {
const tab = delivery.tabs.shift();
if (tab && !tab.closed) {
try {
tab.location.replace(item.url);
void markRead(item).catch(onError);
} catch {
tab.close();
blockedLinks.value.push(item);
}
} else blockedLinks.value.push(item);
}
}
if (request.transferStatus === 'stored' && delivery.handled.size >= request.count) abort(id);
}
}
function opened(item) {
blockedLinks.value = blockedLinks.value.filter((i) => i.accessId !== item.accessId);
void markRead(item).catch(onError);
}
function dispose() {
for (const id of pending.keys()) abort(id);
blockedLinks.value = [];
}
return {
reserve,
abort,
reconcile,
blockedLinks,
opened,
dispose,
hasPending: () => pending.size > 0,
};
}

6
apps/web/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { initialTheme, applyTheme } from './theme';
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
applyTheme(initialTheme());
createApp(App).mount('#app');

View File

@@ -0,0 +1,19 @@
/** Keep invalidations arriving during an in-flight snapshot. Returning the existing promise
* alone discards later changes and can strand an accepted transfer in its pending snapshot. */
export function createRefreshQueue(load) {
let requested = false;
let running;
return function refresh() {
requested = true;
if (running) return running;
running = (async () => {
do {
requested = false;
await load();
} while (requested);
})().finally(() => {
running = undefined;
});
return running;
};
}

3116
apps/web/src/style.css Normal file

File diff suppressed because it is too large Load Diff

23
apps/web/src/theme.js Normal file
View File

@@ -0,0 +1,23 @@
export function initialTheme() {
const saved = localStorage.getItem('hop-theme');
const current = localStorage.getItem('hop-theme-version') === '2';
const theme =
saved === 'light' || saved === 'dark'
? saved
: saved === 'system' && current
? 'system'
: 'dark';
localStorage.setItem('hop-theme', theme);
localStorage.setItem('hop-theme-version', '2');
return theme;
}
export function applyTheme(theme) {
const dark =
theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', dark ? '#000000' : '#fafafa');
localStorage.setItem('hop-theme', theme);
localStorage.setItem('hop-theme-version', '2');
}

15
apps/web/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
host: '127.0.0.1',
port: 0,
strictPort: false,
proxy: {
'/api': process.env.HOP_API_URL ?? 'http://127.0.0.1:0',
'/socket.io': { target: process.env.HOP_API_URL ?? 'http://127.0.0.1:0', ws: true },
},
},
});

24
compose.yaml Normal file
View File

@@ -0,0 +1,24 @@
services:
hop:
build: .
restart: unless-stopped
env_file: .env
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: ${HOP_CONTAINER_PORT:?배포 시 내부 포트를 지정하세요}
APP_URL: ${APP_URL:?서비스 HTTPS 주소를 지정하세요}
ports:
- '${HOP_BIND_IP:?접속을 허용할 호스트 IP를 지정하세요}:${HOP_HOST_PORT:?충돌 없는 호스트 포트를 지정하세요}:${HOP_CONTAINER_PORT:?내부 포트를 지정하세요}'
volumes:
- hop_database:/data/database
- hop_uploads:/data/uploads
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: 10m
max-file: '3'
volumes:
hop_database:
hop_uploads:

202
deploy.md Normal file
View File

@@ -0,0 +1,202 @@
# hop. 유그린 NAS 배포 순서
**SSH에서는 소스와 설정만 준비합니다. 시작·중지·재빌드는 NAS의 Docker 앱에서 합니다.**
접속 경로: `https://hop.noname.company → 기존 Cloudflare Tunnel → NPM(기존 8080) → NAS 내부 IP:50189 → hop.`
아래 코드 블록을 하나씩 복사하고 Enter를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요.
## 1. SSH에서 소스 받기
NAS에서 프로젝트를 둘 상위 폴더까지 이동한 상태에서 실행합니다.
```sh
git --version
```
```sh
docker compose version
```
```sh
openssl version
```
명령이 없으면 해당 단계의 오류를 알려주세요. NAS에는 Node.js나 npm을 설치할 필요가 없습니다.
```sh
git clone https://git.sori.studio/noname.company/hop.noname.company.git hop
```
Git 로그인을 물으면 Git 서버의 로그인 아이디와 비밀번호 또는 액세스 토큰을 입력합니다. 비밀번호 입력 시 화면에 글자가 안 나오는 것은 정상입니다.
```sh
cd hop
```
```sh
git branch --show-current
```
`main`이 나오면 다음으로 넘어갑니다.
## 2. NAS 설정 만들기
```sh
sh scripts/configure-nas.sh
```
질문이 나오면 순서대로 입력합니다.
1. **NAS 내부 IPv4 주소**: NAS 설정에 표시된 실제 내부 IP 입력 → Enter. NPM도 이 IP로 연결합니다.
2. **Resend API 키**: 기존 `hello@noname.company` 발신에 사용하는 키 붙여넣기 → Enter. 입력 문자는 숨겨집니다. 메일 기능 없이 시작하려면 Enter만 누릅니다.
3. **회원가입 활성화**: 가입까지 테스트하려면 `y` → Enter. 비활성화하려면 Enter만 누릅니다. 키를 생략하면 이 질문 없이 가입이 비활성화됩니다.
`SESSION_SECRET`과 `DISCOVERY_SECRET`은 자동 생성됩니다. NAS 포트와 컨테이너 포트는 모두 **50189**를 사용합니다.
마지막에 표시된 `compose.nas.yaml` 경로를 복사해 둡니다. `.env`는 NAS에만 남고 Git에 올라가지 않습니다. 기존 `.env`가 있으면 덮어쓰지 않습니다.
## 3. Docker 앱에서 배포
1. NAS의 **Docker 앱** 열기.
2. **프로젝트 → 생성** 누르기.
3. 프로젝트 이름에 **hop** 입력.
4. 프로젝트 저장 경로는 방금 `git clone`한 **hop 폴더** 선택.
5. YAML 가져오기에서 **compose.nas.yaml** 선택. 파일 가져오기가 없으면 해당 파일 내용을 Compose 편집기에 붙여넣습니다.
6. **배포 / 빌드 후 시작** 누르기. 처음에는 이미지 빌드가 끝날 때까지 기다립니다.
7. 프로젝트의 hop 컨테이너가 **실행 중**, 상태가 **healthy**인지 확인.
버전별로 버튼 이름이 다를 수 있습니다. 생성한 YAML은 소스와 `.env`의 절대 경로를 사용합니다. 별도의 빈 폴더로 소스를 옮기지 마세요. 프로젝트 배포 방식은 [UGREEN 공식 안내](https://ai.ugreen.com/blogs/knowledge/docker-docker-compose-ugreen-nas)를 참고했습니다.
## 4. Nginx Proxy Manager 설정
기존 NPM 관리 화면에서 진행합니다.
1. **Hosts → Proxy Hosts → Add Proxy Host** 누르기. 이미 hop. 항목이 있으면 **Edit**.
2. 다음 값 입력.
| 항목 | 입력값 |
| --------------------- | -------------------------------- |
| Domain Names | `hop.noname.company` |
| Scheme | `http` |
| Forward Hostname / IP | 2단계에서 입력한 **NAS 내부 IP** |
| Forward Port | `50189` |
| Cache Assets | 끄기 |
| Websockets Support | 켜기 |
3. **Advanced**에 다음 내용 입력. 동일한 설정이 있으면 중복으로 넣지 말고 기존 값을 수정합니다.
```nginx
client_max_body_size 2048m;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
```
4. **Save** 누르기.
NPM이 Docker 컨테이너라면 Forward IP에 `127.0.0.1`을 입력하지 마세요. 여기서는 **NAS 내부 IP**를 사용합니다. 기존 Tunnel이 NPM의 8080으로 들어오는 설정은 그대로 유지합니다. NPM에 별도 인증서나 Force SSL을 추가하는 단계는 필요하지 않습니다. 공개 HTTPS는 현재 Cloudflare 경로를 사용합니다. NPM은 [공식 문서](https://nginxproxymanager.com/guide/)의 도메인별 전달 기능을 사용합니다.
## 5. Cloudflare Tunnel에 주소 연결
1. 기존 터널의 **Published application routes / Public Hostnames** 열기.
2. `hop.noname.company`가 기존 NPM으로 전달되는지 확인.
3. 주소가 없다면 `hop.noname.company` 추가.
4. Service는 **다른 서비스에서 이미 사용 중인 NPM의 8080 주소** 그대로 입력.
5. 저장.
8080을 50189로 바꾸지 않습니다. Tunnel은 NPM으로, NPM의 hop. 항목은 50189로 전달합니다.
## 6. 동작 확인
브라우저에서 다음 주소를 엽니다.
```text
https://hop.noname.company
```
1. PC에서 접속.
2. **QR로 연결**을 눌러 연결 공간 만들기.
3. 휴대폰에서 QR을 스캔하거나 다른 브라우저에서 연결 코드 입력.
4. 참여 후 QR 모달이 닫히고 상대 기기가 보이는지 확인.
5. 상대 기기 선택 → 링크 전송 → 상대에서 수락 → 링크가 열리는지 확인.
6. 작은 파일 전송 → 상대에서 수락 → 다운로드 확인.
7. 가입을 활성화했다면 **로그인 → 회원가입**에서 실제 이메일로 가입 → Resend 인증 메일 확인 → 로그인.
8. 로그인 상태로 받은 자료가 **보관함 → 받은 항목**에 표시되는지 확인.
처음 배포는 **QR·코드 연결**로 확인합니다. 주변 자동 발견은 실제 클라이언트 IP 전달과 프록시 신뢰 범위를 확인한 다음 활성화해야 합니다. 현재 설정에서는 꺼져 있습니다. 임의로 `TRUSTED_PROXY_CIDRS`에 모든 IP를 넣지 마세요.
운영 비밀번호는 **12자 이상**입니다. 개발 DB와 `1234` 테스트 계정은 NAS에 복사되지 않습니다.
**대용량 파일:** Cloudflare를 통과하는 업로드에는 요금제 제한이 있습니다. 현재 공식 안내는 Free/Pro **100MB**, Business **200MB**입니다. NPM의 `2048m` 설정으로 이 제한이 늘어나지는 않습니다. 큰 파일에서 413이 나오면 [Cloudflare 업로드 제한](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-413/)을 확인하세요. 현재 hop.은 파일을 작은 요청으로 나누어 업로드하지 않습니다.
## 7. 다음 버전으로 업데이트
SSH에서 같은 hop 폴더까지 이동한 다음 진행합니다.
1. Docker 앱 → **프로젝트 → hop → 중지**.
2. SSH에서 데이터 백업:
```sh
mkdir -p backups
```
```sh
chmod 700 backups
```
```sh
sudo docker volume inspect hop_hop_database hop_hop_uploads >/dev/null
```
```sh
sudo docker run --rm -v hop_hop_database:/source/database:ro -v hop_hop_uploads:/source/uploads:ro -v "$PWD/backups:/backup" alpine:3.22 sh -c 'tar -czf "/backup/hop-$(date +%Y%m%d-%H%M%S).tar.gz" -C /source database uploads'
```
```sh
cp -p .env "backups/env-$(date +%Y%m%d-%H%M%S)"
```
3. 최신 코드 받기:
```sh
git pull --ff-only origin main
```
4. 배포 YAML 다시 만들기. 기존 `.env`와 비밀값은 유지됩니다.
```sh
sh scripts/configure-nas.sh
```
5. Docker 앱 → **프로젝트 → hop → 편집**.
6. 최신 **compose.nas.yaml**을 다시 불러오거나 편집기에 붙여넣기 → 저장.
7. **재빌드 / 이미지 다시 빌드** 선택 → **시작 / 재배포**. 단순 재시작만 하면 바뀐 코드가 이미지에 반영되지 않습니다.
8. `healthy` 확인 → 6단계의 링크·파일 전송 확인.
프로젝트를 삭제하거나 `docker compose down -v`를 실행하지 마세요. DB·파일은 `hop_hop_database`, `hop_hop_uploads` 볼륨에 남습니다. 업데이트 실패 시 중지하고 오류를 확인하세요. DB 마이그레이션이 적용됐을 수 있으므로 코드만 되돌리지 말고 같은 시점의 DB·업로드·환경파일 백업으로 함께 복구해야 합니다.
## 8. 시작 / 중지 / 로그
| 할 일 | 누를 위치 |
| -------------- | ----------------------------------------------------------------------- |
| 시작 | Docker → 프로젝트 → hop → 시작 |
| 중지 | Docker → 프로젝트 → hop → 중지 |
| 설정 변경 적용 | `.env` 수정 → YAML 재생성 → 프로젝트 편집 → YAML 다시 불러오기 → 재배포 |
| 오류 확인 | Docker → 컨테이너 → hop 컨테이너 → 로그 |
SSH에서 실제 설정 중 비밀값 없이 주소·포트만 확인하려면:
```sh
grep -E '^(APP_URL|HOP_BIND_IP|HOP_HOST_PORT|HOP_CONTAINER_PORT)=' .env
```
NAS에서 앱의 상태만 확인하려면:
```sh
HOP_NAS_IP=$(sed -n 's/^HOP_BIND_IP=//p' .env)
curl -fsS "http://${HOP_NAS_IP}:50189/api/health"
```
HTTP 주소는 상태 확인에만 씁니다. 실제 접속·QR·로그인은 `https://hop.noname.company`를 사용하세요.
환경파일을 YAML에 포함하지 않는 생성 방식은 [Docker Compose 공식 문서](https://docs.docker.com/reference/cli/docker/compose/config/)의 `--no-env-resolution`을 사용합니다. `.env`, 생성된 `compose.nas.yaml`, `backups`는 Git과 Docker 빌드에서 제외됩니다.

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 경로는 이번 자동 검증에 포함하지 않음.

6508
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "hop",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "npm run build -w @hop/shared && npm run build -w @hop/web && npm run build -w @hop/server",
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test -w @hop/server",
"dev": "node scripts/dev.mjs",
"start": "npm run start -w @hop/server",
"db:generate": "npm run db:generate -w @hop/server",
"test:e2e": "node --import tsx tests/e2e/phase1.mjs",
"format": "prettier --write apps packages scripts tests compose.yaml Dockerfile package.json tsconfig.base.json docs README.md .env.example --ignore-unknown",
"test:e2e:account": "node --import tsx tests/e2e/phase3.mjs",
"test:e2e:devices": "node --import tsx tests/e2e/device-menu.mjs",
"test:e2e:layout": "node --import tsx tests/e2e/layout.mjs"
},
"engines": {
"node": ">=22.12"
},
"devDependencies": {
"@playwright/test": "^1.58.0",
"@types/node": "^22.19.0",
"prettier": "^3.9.9",
"typescript": "^5.9.3"
},
"overrides": {
"@esbuild-kit/core-utils": {
"esbuild": "^0.25.12"
}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@hop/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "^4.3.0"
}
}

View File

@@ -0,0 +1,175 @@
import { z } from 'zod';
export const devicePatch = z
.object({
name: z
.string()
.trim()
.min(1)
.max(40)
.refine((v) => !/[\x00-\x1f\x7f<>]/.test(v), '제어문자와 HTML 문자는 사용할 수 없습니다.')
.optional(),
discoverable: z.boolean().optional(),
})
.strict();
export const fileMetadata = z
.object({
name: z
.string()
.min(1)
.max(255)
.refine((v) => !/[\x00-\x1f\x7f]/.test(v)),
size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
type: z.string().max(255).default('application/octet-stream'),
})
.strict();
export const requestInput = z
.object({
targetDeviceId: z.uuid(),
kind: z.enum(['url', 'file']),
byteSize: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
count: z.number().int().min(1).max(1000),
files: z.array(fileMetadata).max(1000).optional(),
})
.strict()
.refine((v) =>
v.kind === 'file'
? Boolean(
v.files?.length === v.count && v.files.reduce((sum, f) => sum + f.size, 0) === v.byteSize,
)
: !v.files,
);
export const completeInput = z
.object({
urls: z.array(z.string().max(8192)).min(1).max(20),
})
.strict();
export const spaceInput = z.object({ approvalRequired: z.boolean().default(false) }).strict();
export const joinInput = z
.object({
code: z
.string()
.trim()
.toUpperCase()
.regex(/^[A-Z2-9]{8}$/)
.optional(),
token: z
.string()
.regex(/^[A-Za-z0-9_-]{43}$/)
.optional(),
})
.strict()
.refine((v) => Boolean(v.code) !== Boolean(v.token));
export type Device = { id: string; name: string; code: string; discoverable: boolean };
export type Peer = Device & { sources: ('network' | 'space')[] };
export type RequestStatus = 'pending' | 'accepted' | 'rejected' | 'expired' | 'cancelled';
export type TransferRequest = {
id: string;
batchId: string;
senderDevice: Device | null;
recipientDevice: Device | null;
kind: 'url' | 'file';
byteSize: number;
count: number;
status: RequestStatus;
transferStatus: 'waiting' | 'uploading' | 'stored' | 'failed';
files?: {
id: string;
name: string;
size: number;
type: string;
status: 'pending' | 'uploading' | 'stored' | 'failed' | 'cleaning';
}[];
createdAt: number;
expiresAt: number;
startBy: number | null;
direction: 'sent' | 'received';
};
export type Item = {
accessId: string;
id: string;
batchId: string;
kind: 'url' | 'file';
url: string | null;
file?: { id: string; name: string; size: number; type: string };
role: 'sender' | 'recipient' | 'self';
read: boolean;
peerName: string;
peerCode: string;
createdAt: number;
expiresAt: number;
recipientRead: boolean;
};
export type Space = {
id: string;
creatorDeviceId: string;
approvalRequired: boolean;
expiresAt: number;
members: { device: Device; status: 'pending' | 'approved' }[];
};
export type SessionResponse = { device: Device; csrfToken: string; expiresAt: number };
export function parseUrls(value: string): { urls: string[]; invalid: string[] } {
const entries = value
.split(/\r?\n/)
.map((v) => v.trim())
.filter(Boolean);
const urls: string[] = [];
const invalid: string[] = [];
for (const v of entries) {
try {
const u = new URL(v);
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) throw Error();
urls.push(u.href);
} catch {
invalid.push(v);
}
}
return { urls, invalid };
}
export type Account = {
id: string;
email: string;
displayName: string;
role: 'user' | 'admin';
status: 'pending' | 'active' | 'suspended' | 'deleted';
verified: boolean;
};
export type AccountItem = Item & {
note?: string | null;
favorite: boolean;
senderEmail?: string;
senderName?: string;
};
export type EmailRequest = {
id: string;
batchId: string;
kind: 'url' | 'file';
byteSize: number;
count: number;
status: RequestStatus;
transferStatus: 'waiting' | 'uploading' | 'stored' | 'failed';
createdAt: number;
expiresAt: number;
startBy: number | null;
direction: 'sent' | 'received';
sender: Account;
recipient: Account;
files?: TransferRequest['files'];
};
export const emailRequestInput = z
.object({
mode: z.literal('email'),
email: z.string().max(254),
kind: requestInput.shape.kind,
byteSize: requestInput.shape.byteSize,
count: requestInput.shape.count,
files: requestInput.shape.files,
})
.strict()
.refine((v) =>
v.kind === 'file'
? Boolean(
v.files?.length === v.count && v.files.reduce((sum, f) => sum + f.size, 0) === v.byteSize,
)
: !v.files,
);

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"declaration": true
},
"include": ["src"]
}

88
scripts/configure-nas.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/bin/sh
# NAS에서 실행합니다. 기존 .env와 비밀값은 유지합니다.
set -eu
cd "$(dirname "$0")/.."
umask 077
hop_env_tmp=''
hop_yaml_tmp=''
hop_echo_hidden=false
cleanup() {
if [ "$hop_echo_hidden" = true ]; then stty echo; fi
if [ -n "$hop_env_tmp" ]; then rm -f "$hop_env_tmp"; fi
if [ -n "$hop_yaml_tmp" ]; then rm -f "$hop_yaml_tmp"; fi
}
trap cleanup 0
trap 'exit 130' INT
trap 'exit 129' HUP
trap 'exit 143' TERM
if ! docker compose config --help | grep -q -- '--no-env-resolution'; then
printf '%s\n' 'Docker 앱/Compose를 업데이트하세요. --no-env-resolution 옵션이 필요합니다.' >&2
exit 1
fi
if [ ! -f .env ]; then
command -v openssl >/dev/null || {
printf '%s\n' 'openssl 명령이 필요합니다.' >&2
exit 1
}
printf 'NAS의 실제 내부 IPv4 주소를 입력하고 Enter: '
read -r hop_nas_ip
if ! printf '%s\n' "$hop_nas_ip" | awk -F. '
NF != 4 { exit 1 }
{ for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1 }
'; then
printf '%s\n' 'NAS의 IPv4 주소를 입력하세요.' >&2
exit 1
fi
printf 'Resend API 키를 입력하고 Enter (건너뛰려면 Enter): '
if [ -t 0 ]; then
stty -echo
hop_echo_hidden=true
fi
read -r hop_resend_key
if [ "$hop_echo_hidden" = true ]; then
stty echo
hop_echo_hidden=false
fi
printf '\n'
case "$hop_resend_key" in
*[!A-Za-z0-9_-]*)
printf '%s\n' 'Resend API 키에는 공백이나 따옴표를 넣지 마세요.' >&2
exit 1 ;;
esac
hop_registration=closed
if [ -n "$hop_resend_key" ]; then
printf '회원가입을 활성화할까요? y 입력 후 Enter / 비활성화는 Enter: '
read -r hop_open_registration
case "$hop_open_registration" in y|Y) hop_registration=open ;; esac
fi
hop_session_secret=$(openssl rand -hex 32)
hop_discovery_secret=$(openssl rand -hex 32)
hop_env_tmp=$(mktemp .env.nas.XXXXXX)
awk '!/^(HOP_BIND_IP|HOP_HOST_PORT|HOP_CONTAINER_PORT|APP_URL|SESSION_SECRET|DISCOVERY_SECRET|RESEND_API_KEY|REGISTRATION_MODE)=/' .env.example > "$hop_env_tmp"
{
printf 'HOP_BIND_IP=%s\n' "$hop_nas_ip"
printf 'HOP_HOST_PORT=50189\nHOP_CONTAINER_PORT=50189\n'
printf 'APP_URL=https://hop.noname.company\n'
printf 'SESSION_SECRET=%s\n' "$hop_session_secret"
printf 'DISCOVERY_SECRET=%s\n' "$hop_discovery_secret"
printf 'RESEND_API_KEY=%s\n' "$hop_resend_key"
printf 'REGISTRATION_MODE=%s\n' "$hop_registration"
} >> "$hop_env_tmp"
mv "$hop_env_tmp" .env
hop_env_tmp=''
unset hop_session_secret hop_discovery_secret hop_resend_key
else
printf '%s\n' '기존 .env를 유지합니다. 비밀값을 다시 생성하지 않습니다.'
fi
chmod 600 .env
hop_yaml_tmp=$(mktemp .compose.nas.XXXXXX)
docker compose --project-name hop --project-directory "$PWD" --env-file .env -f compose.yaml config --no-env-resolution > "$hop_yaml_tmp"
mv "$hop_yaml_tmp" compose.nas.yaml
hop_yaml_tmp=''
chmod 600 compose.nas.yaml
printf '\n%s\n' '완료. Docker 앱에서 프로젝트 이름 hop으로 아래 YAML을 불러오세요.'
printf '%s/compose.nas.yaml\n' "$PWD"

35
scripts/dev-runtime.mjs Normal file
View File

@@ -0,0 +1,35 @@
import { createServer } from 'vite';
import { loadConfig } from '../apps/server/src/config.ts';
import { createApp } from '../apps/server/src/app.ts';
const cfg = loadConfig({
...process.env,
NODE_ENV: 'development',
SAME_NETWORK_DISCOVERY_ENABLED: 'true',
PAIR_REQUESTS_PER_MINUTE: '20',
GUEST_REQUESTS_PER_MINUTE: '60',
IP_REQUESTS_PER_MINUTE: '120',
PORT: process.env.HOP_DEV_API_PORT,
DATABASE_PATH: process.env.HOP_DEV_DATABASE_PATH ?? '.data/hop.sqlite',
UPLOAD_DIR: process.env.HOP_DEV_UPLOAD_DIR ?? '.data/uploads',
});
const runtime = await createApp(cfg);
await runtime.app.listen({ port: cfg.port, host: '127.0.0.1' });
process.env.HOP_API_URL = `http://127.0.0.1:${cfg.port}`;
const vite = await createServer({
root: 'apps/web',
configFile: 'apps/web/vite.config.js',
server: { port: Number(process.env.HOP_DEV_WEB_PORT), strictPort: true, host: '127.0.0.1' },
});
await vite.listen();
const url = vite.resolvedUrls.local[0];
runtime.setDevOrigin(new URL(url).origin);
cfg.appUrl = new URL(url).origin;
process.stdout.write(
`\nhop. 개발 화면: ${url}\nAPI: ${process.env.HOP_API_URL}\n휴대폰 접속은 아직 설정하지 않았습니다. QR 대신 같은 PC의 다른 브라우저에서 코드를 입력해 주세요.\n`,
);
for (const signal of ['SIGINT', 'SIGTERM'])
process.on(signal, async () => {
await vite.close();
await runtime.app.close();
process.exit(0);
});

49
scripts/dev.mjs Normal file
View File

@@ -0,0 +1,49 @@
import { loadEnvFile } from 'node:process';
import { existsSync } from 'node:fs';
import { createServer } from 'node:net';
import { spawn } from 'node:child_process';
if (existsSync('.env')) loadEnvFile('.env');
async function availablePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') throw Error('No development port');
const port = address.port;
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
return port;
}
const build = spawn('npm', ['run', 'build', '-w', '@hop/shared'], { stdio: 'inherit' });
if ((await new Promise((resolve) => build.on('exit', resolve))) !== 0) process.exit(1);
const apiPort = await availablePort(),
webPort = await availablePort();
if (apiPort === webPort) throw Error('Port allocation conflicted; run npm run dev again.');
const child = spawn(
'node',
[
'--watch',
'--watch-path=apps/server/src',
'--watch-path=apps/server/drizzle',
'--watch-path=packages/shared/dist',
'--watch-path=scripts/dev-runtime.mjs',
'--watch-preserve-output',
'--import',
'tsx',
'scripts/dev-runtime.mjs',
],
{
stdio: 'inherit',
env: {
...process.env,
HOP_DEV_API_PORT: String(apiPort),
HOP_DEV_WEB_PORT: String(webPort),
NODE_ENV: 'development',
},
},
);
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal));
child.on('exit', (code) => process.exit(code ?? 0));

View File

@@ -0,0 +1,75 @@
import { loadEnvFile } from 'node:process';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { eq } from 'drizzle-orm';
import { loadConfig } from '../apps/server/src/config.ts';
import { createApp } from '../apps/server/src/app.ts';
import { users } from '../apps/server/src/db/schema.ts';
try {
loadEnvFile('.env');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (process.env.NODE_ENV === 'production') throw Error('개발 환경에서만 실행할 수 있습니다.');
const cfg = loadConfig({
...process.env,
NODE_ENV: 'development',
DATABASE_PATH: process.env.HOP_DEV_DATABASE_PATH ?? resolve('.data/hop.sqlite'),
UPLOAD_DIR: process.env.HOP_DEV_UPLOAD_DIR ?? resolve('.data/uploads'),
});
const runtime = await createApp(cfg);
try {
const password = '1234';
const passwordHash = await runtime.service.auth.password(password);
const accounts = [1, 2].map((n) => ({
email: `test${n}@hop.test`,
displayName: `hop. 테스트 ${n}`,
}));
// Only these named development fixtures may have their passwords reset.
for (const account of accounts) {
const existing = runtime.service.db
.select()
.from(users)
.where(eq(users.email, account.email))
.get();
if (existing && (existing.displayName !== account.displayName || existing.role !== 'user'))
throw Error(`테스트 계정이 아닌 기존 계정입니다: ${account.email}`);
}
runtime.service.db.transaction(() => {
for (const account of accounts) {
const existing = runtime.service.db
.select()
.from(users)
.where(eq(users.email, account.email))
.get();
if (existing)
runtime.service.db
.update(users)
.set({ passwordHash })
.where(eq(users.id, existing.id))
.run();
else
runtime.service.db
.insert(users)
.values({
id: randomUUID(),
...account,
passwordHash,
role: 'user',
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
}
});
for (const account of accounts) {
const result = await runtime.service.auth.login(account.email, password, 'local-test-seed');
if (!result.account.verified) throw Error('인증 상태 검증 실패');
runtime.service.auth.logout(result.token);
console.log(`${account.email}: 생성 및 로그인 검증 완료`);
}
} finally {
await runtime.app.close();
}

View File

@@ -0,0 +1,86 @@
import { execFileSync } from 'node:child_process';
import { randomBytes, randomInt, randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
const suffix = randomUUID().slice(0, 8),
name = `hop-check-${suffix}`,
volume = `hop-check-db-${suffix}`,
uploads = `hop-check-files-${suffix}`;
const port = String(randomInt(20000, 60000)); // Isolated container only; no host port is published.
const docker = (...args) =>
execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
const node = (code) => docker('exec', name, 'node', '--input-type=module', '-e', code);
try {
docker('volume', 'create', volume);
docker('volume', 'create', uploads);
docker(
'run',
'-d',
'--name',
name,
'--network',
'none',
'-e',
'NODE_ENV=production',
'-e',
`PORT=${port}`,
'-e',
'APP_URL=https://hop.test',
'-e',
`SESSION_SECRET=${randomBytes(32).toString('hex')}`,
'-e',
`DISCOVERY_SECRET=${randomBytes(32).toString('hex')}`,
'-v',
`${volume}:/data/database`,
'-v',
`${uploads}:/data/uploads`,
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
);
const wait = () =>
node(
`for(let i=0;i<60;i++){try{const r=await fetch('http://127.0.0.1:${port}/api/health');if(r.ok){console.log('ready');break}}catch{}if(i===59)throw Error('not ready');await new Promise(r=>setTimeout(r,250))}`,
);
wait();
const state = JSON.parse(
node(
`const base='http://127.0.0.1:${port}';const r=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test','Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error('session failed');const session=await r.json(),cookie=r.headers.get('set-cookie').split(';')[0];if(!r.headers.get('set-cookie').includes('Secure'))throw Error('insecure cookie');const renamed=await fetch(base+'/api/device/me',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({name:'Docker 저장 검증'})});if(!renamed.ok)throw Error('rename failed');console.log(JSON.stringify({cookie,id:session.device.id}));`,
),
);
const fileState = JSON.parse(
node(
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId}));`,
),
);
docker('restart', name);
wait();
const result = JSON.parse(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/device/me',{headers:{Cookie:${JSON.stringify(state.cookie)}}});console.log(JSON.stringify(await r.json()));`,
),
);
assert.equal(result.device.name, 'Docker 저장 검증');
assert.equal(result.device.id, state.id);
const restored = node(
`const r=await fetch('http://127.0.0.1:${port}/api/access/${fileState.accessId}/download',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('download failed');console.log(await r.text());`,
);
assert.equal(restored, 'Docker file persistence');
const integrity = node(
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(db.pragma('integrity_check',{simple:true}));db.close();`,
);
assert.equal(integrity, 'ok');
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
console.log(
'PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file persistence after restart, SQLite integrity, no host ports published.',
);
} catch (error) {
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
console.error(docker('inspect', '--format', '{{json .State}}', name));
throw error;
} finally {
try {
docker('rm', '-f', name);
} catch {}
for (const v of [volume, uploads])
try {
docker('volume', 'rm', v);
} catch {}
}

116
tests/e2e/device-menu.mjs Normal file
View File

@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { chromium } from '@playwright/test';
import { mkdtemp, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { loadConfig } from '../../apps/server/src/config.ts';
import { createApp } from '../../apps/server/src/app.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-devices-e2e-'));
const runtime = await createApp({
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
});
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const { port } = runtime.app.server.address(),
url = `http://127.0.0.1:${port}`;
runtime.setDevOrigin(url);
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const hostContext = await browser.newContext({ viewport: { width: 1440, height: 1050 } }),
guestContext = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
const host = await hostContext.newPage(),
guest = await guestContext.newPage(),
errors = [];
for (const page of [host, guest]) page.on('pageerror', (error) => errors.push(error.message));
await Promise.all([host.goto(url), guest.goto(url)]);
async function rename(page, name) {
await page.locator('.device-name').click();
await page.getByRole('textbox', { name: '기기 이름' }).fill(name);
await page.getByRole('button', { name: '이름 저장' }).click();
await page.locator('.device-name').filter({ hasText: name }).waitFor();
}
await rename(host, '방장 Mac');
await rename(guest, '외부 기기');
await host.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await host.getByRole('checkbox', { name: '참여할 때 내 승인 받기' }).check();
await host.getByRole('button', { name: '연결 공간 만들기' }).click();
await host.locator('.invite-code').waitFor();
const code = (await host.locator('.invite-code').innerText()).replace(/\s/g, '');
await host.getByRole('button', { name: '대화상자 닫기' }).click();
await host.locator('.my-device-info .host-badge').waitFor();
await guest.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await guest.getByRole('textbox', { name: '연결 코드' }).fill(code);
await guest.getByRole('button', { name: '참여', exact: true }).click();
await guest.locator('.room-leave').waitFor();
await guest.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
const guestCard = host.locator('.peer-entry').filter({ hasText: '외부 기기' });
await guestCard.getByText('승인 대기', { exact: true }).waitFor();
assert.equal(await guestCard.locator('.peer-card').isDisabled(), true);
await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click();
await guestCard.getByRole('menuitem', { name: '참여 승인', exact: true }).click();
await guestCard.getByText('온라인', { exact: true }).waitFor();
assert.equal(await guestCard.locator('.peer-card').isEnabled(), true);
const hostCard = guest.locator('.peer-entry').filter({ hasText: '방장 Mac' });
await hostCard.locator('.host-badge').waitFor();
assert.equal(await guest.getByRole('button', { name: '방장 Mac 기기 더보기' }).count(), 0);
for (const page of [host, guest]) {
assert.equal(await page.locator('.spaces-list,.space-member').count(), 0);
assert(!(await page.locator('body').innerText()).includes('참여 중'));
}
await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click();
const kick = guestCard.getByRole('menuitem', { name: '내보내기', exact: true });
await kick.waitFor();
const rgb = await kick.evaluate((node) => getComputedStyle(node).color);
const components = rgb.match(/\d+/g).map(Number);
assert(components[0] > components[1] && components[0] > components[2]);
await host.keyboard.press('Escape');
assert.equal(await host.locator('.device-menu').count(), 0);
await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click();
await host.locator('.stage-heading').click();
assert.equal(await host.locator('.device-menu').count(), 0);
await guestCard.getByRole('button', { name: '외부 기기 기기 더보기' }).click();
await host.screenshot({ path: 'tests/e2e/device-menu-desktop.png', fullPage: true });
await guest.screenshot({ path: 'tests/e2e/device-menu-mobile.png', fullPage: true });
await guestCard.getByRole('menuitem', { name: '내보내기', exact: true }).click();
await guestCard.waitFor({ state: 'hidden' });
await hostCard.waitFor({ state: 'hidden' });
assert.equal(await guest.locator('.room-leave').count(), 0);
await host.getByRole('button', { name: '방 나가기', exact: true }).click();
await host.locator('.room-leave').waitFor({ state: 'hidden' });
await host.locator('.my-device-info .host-badge').waitFor({ state: 'hidden' });
assert.equal(
await guest.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
assert.deepEqual(errors, []);
console.log(
'PASS: HOST badge on current/peer devices; approval and red kick action in main card menu; non-host has no kick menu; Escape/outside close; no duplicate participant list; kick propagates to both browsers; space close and mobile viewport.',
);
} catch (error) {
console.error(error);
for (const context of browser?.contexts() ?? [])
for (const page of context.pages())
console.error((await page.locator('body').innerText()).slice(0, 4000));
throw error;
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}

180
tests/e2e/layout.mjs Normal file
View File

@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import { chromium } from '@playwright/test';
import { io } from 'socket.io-client';
import { mkdtemp, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-layout-'));
const runtime = await createApp({
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
});
const sockets = [];
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
runtime.setDevOrigin(url);
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const context = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
const page = await context.newPage(),
errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await context.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: async () => window.__layoutClipboard ?? 'https://example.com/layout',
writeText: async () => {},
},
});
});
await page.goto(url);
await page.locator('.app-shell[data-online="true"]').waitFor();
for (let index = 0; index < 12; index++) {
const session = runtime.service.createGuest();
runtime.service.patchDevice(session.guest, { name: `기기 ${index + 1}` });
const socket = io(url, {
transports: ['websocket'],
auth: { csrfToken: runtime.service.csrf(session.token) },
extraHeaders: { Origin: url, Cookie: `hop_guest=${session.token}` },
});
sockets.push(socket);
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
});
}
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
assert.equal(await page.locator('.workspace-grid,.send-panel,.send-dialog').count(), 0);
assert.equal(await page.locator('.desktop-nav,.mobile-nav,.connection-status').count(), 0);
for (const [width, columns] of [
[1280, 4],
[800, 3],
[390, 2],
]) {
await page.setViewportSize({ width, height: 900 });
assert.equal(
await page
.locator('.peer-grid')
.evaluate((node) => getComputedStyle(node).gridTemplateColumns.split(' ').length),
columns,
);
const grid = await page.locator('.peer-grid').boundingBox(),
mine = await page.locator('.device-footer').boundingBox();
assert(mine.y >= grid.y + grid.height, '내 기기는 푸터');
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
await page.screenshot({ path: `tests/e2e/layout-${width}.png`, fullPage: true });
}
await page.locator('.peer-card').first().click();
const sheet = page.getByRole('dialog', { name: /에 보내기/ });
await sheet.waitFor();
const bounds = await sheet.boundingBox();
assert(Math.abs(bounds.y + bounds.height - 900) < 2, '모바일 하단 시트');
assert.equal(await page.locator('.content-submit').count(), 1);
assert.equal(
await page.getByRole('button', { name: '보관함에서 가져오기', exact: true }).count(),
0,
);
const chooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: '대신 파일 선택하기', exact: true }).click();
await (
await chooser
).setFiles({
name: 'replacement.txt',
mimeType: 'text/plain',
buffer: Buffer.from('replace the URL'),
});
await page.locator('.selected-files').filter({ hasText: 'replacement.txt' }).waitFor();
assert.equal(await page.getByLabel('보낼 URL').count(), 0, '파일이 링크를 대체');
assert.equal(await page.locator('.content-submit').count(), 1);
const fileBounds = await sheet.boundingBox();
assert.equal(fileBounds.height, bounds.height);
assert.equal(fileBounds.width, bounds.width);
await page.evaluate(() => {
window.__layoutClipboard = '';
});
await page.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await page.getByLabel('보낼 URL').waitFor();
assert.equal(await page.locator('.selected-files').count(), 0);
assert.equal(await page.locator('.content-submit').isDisabled(), true);
await page.getByLabel('보낼 URL').fill('https://example.com/manual');
assert.equal(await page.locator('.content-submit').isEnabled(), true);
const linkBounds = await sheet.boundingBox();
assert.equal(linkBounds.height, bounds.height);
assert.equal(linkBounds.width, bounds.width);
await page.screenshot({ path: 'tests/e2e/send-sheet.png' });
await page.getByRole('button', { name: '전송 화면 닫기' }).click();
await page.setViewportSize({ width: 1280, height: 1000 });
await page.locator('.peer-card').first().click();
const dialog = await page.locator('.send-dialog').boundingBox();
assert(
Math.abs(dialog.x + dialog.width / 2 - 640) < 2 &&
Math.abs(dialog.y + dialog.height / 2 - 500) < 2,
'PC 중앙 모달',
);
await page.screenshot({ path: 'tests/e2e/send-dialog.png' });
await page.keyboard.press('Escape');
await sheet.waitFor({ state: 'hidden' });
await page.locator('.footer-device-summary').click();
await page.getByLabel('기기 이름', { exact: true }).fill('나의 테스트 기기');
await page.getByRole('button', { name: '이름 저장', exact: true }).click();
await page.locator('.footer-device-name').filter({ hasText: '나의 테스트 기기' }).waitFor();
await page.locator('.footer-device-summary').click();
await page.getByLabel('다른 기기에서 발견 허용').uncheck();
await page.locator('.footer-device-summary').filter({ hasText: '발견 꺼짐' }).waitFor();
await page.getByLabel('다른 기기에서 발견 허용').check();
await page.locator('.footer-device-summary').filter({ hasText: '발견 허용' }).waitFor();
await page.getByRole('button', { name: '대화상자 닫기', exact: true }).click();
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await page.getByRole('button', { name: '연결 공간 만들기' }).click();
const code = (await page.locator('.invite-code').innerText()).replace(/\s/g, '');
const cookie = sockets[0].io.opts.extraHeaders.Cookie;
const token = cookie.split('=')[1];
const response = await fetch(`${url}/api/spaces/join`, {
method: 'POST',
headers: {
Origin: url,
Cookie: cookie,
'X-CSRF-Token': runtime.service.csrf(token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
assert.equal(response.status, 200);
await page.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 1);
await page.screenshot({ path: 'tests/e2e/main-single-device.png', fullPage: true });
assert.equal(
await page.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).count(),
0,
);
await page.getByRole('button', { name: '방 나가기', exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll('.peer-card').length === 12);
assert.deepEqual(errors, []);
console.log(
'PASS: 실제 12대 자동 발견, 4/3/2열과 줄바꿈, 푸터의 내 기기 상태·이름/발견 설정, 선택 전 전송 UI 없음, 고정 크기 PC 모달·모바일 하단 시트, 하나의 전송 버튼과 파일/링크 대체, Escape, QR 참여 시 모달 자동 닫힘, 방 안의 기기 격리·나가기 후 주변 복귀.',
);
} finally {
for (const socket of sockets) socket.disconnect();
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}

409
tests/e2e/phase1.mjs Normal file
View File

@@ -0,0 +1,409 @@
import assert from 'node:assert/strict';
import { chromium } from '@playwright/test';
import { resolve } from 'node:path';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-e2e-'));
const runtime = await createApp({
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
PAIR_REQUESTS_PER_MINUTE: '5',
GUEST_REQUESTS_PER_MINUTE: '10',
IP_REQUESTS_PER_MINUTE: '40',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
});
let browser;
const requestCounts = {};
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const { port } = runtime.app.server.address();
const url = `http://127.0.0.1:${port}`;
runtime.setDevOrigin(url);
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const a = await browser.newContext({ viewport: { width: 1440, height: 1050 } }),
b = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
await a.addInitScript(() => {
window.__hopClipboardReads = 0;
window.__hopClipboard = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: async () => {
window.__hopClipboardReads++;
if (window.__hopClipboardDenied) throw new DOMException('Denied', 'NotAllowedError');
return window.__hopClipboard;
},
writeText: async () => {},
},
});
});
await a.addInitScript(() => {
if (!localStorage.getItem('hop-theme-version')) localStorage.setItem('hop-theme', 'system');
});
await b.route('https://example.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><title>Received link</title><h1>수신한 링크 열기 검증</h1>',
}),
);
const sender = await a.newPage(),
receiver = await b.newPage();
const errors = [];
for (const page of [sender, receiver])
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (path.startsWith('/api')) requestCounts[path] = (requestCounts[path] ?? 0) + 1;
});
sender.on('pageerror', (e) => errors.push(e.message));
receiver.on('pageerror', (e) => errors.push(e.message));
await Promise.all([sender.goto(url), receiver.goto(url)]);
await sender.locator('.device-name').waitFor();
await receiver.locator('.device-name').waitFor();
await sender.locator('.app-shell[data-online="true"]').waitFor();
await receiver.locator('.app-shell[data-online="true"]').waitFor();
await sender.locator('.device-name').click();
await sender.getByRole('textbox', { name: '기기 이름' }).fill('보내는 Mac');
await sender.getByRole('button', { name: '이름 저장' }).click();
await sender.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor();
await sender.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await sender.getByRole('button', { name: '연결 공간 만들기' }).click();
await sender.locator('.invite-code').waitFor();
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
await receiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await receiver.getByRole('textbox', { name: '연결 코드' }).fill(code);
await receiver.getByRole('button', { name: '참여', exact: true }).click();
await receiver.locator('.room-leave').waitFor();
await sender.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
await receiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
await sender.locator('.peer-card').first().waitFor();
await sender.locator('.peer-card').first().click();
const secret = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
await sender.waitForFunction(
(value) => document.querySelector('.current-url')?.value === value,
secret,
);
assert.equal(await sender.evaluate(() => window.__hopClipboardReads), 1);
await sender.evaluate(() => {
window.__hopClipboard = 'https://example.com/focus-refresh';
window.dispatchEvent(new Event('focus'));
});
await sender.waitForFunction(
(value) => document.querySelector('.current-url')?.value === value,
'https://example.com/focus-refresh',
);
await sender.evaluate(() => {
window.__hopClipboard = 'https://example.com/reselect-refresh';
});
await sender.getByRole('button', { name: '전송 화면 닫기' }).click();
await sender.locator('.peer-card').first().click();
await sender.waitForFunction(
(value) => document.querySelector('.current-url')?.value === value,
'https://example.com/reselect-refresh',
);
await sender.evaluate(() => {
window.__hopClipboard = 'https://example.com/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E';
});
await sender.getByRole('button', { name: '클립보드 가져오기', exact: true }).click();
await sender.waitForFunction(
(value) => document.querySelector('.current-url')?.value === value,
secret,
);
assert.equal(await sender.locator('.desktop-nav, .mobile-nav, .connection-status').count(), 0);
assert.equal(await sender.getByRole('button', { name: '설정', exact: true }).count(), 0);
assert.equal(await sender.locator('.input-tabs').count(), 0);
assert.equal(await sender.getByRole('button', { name: '파일 선택', exact: true }).count(), 0);
assert.equal(await sender.locator('.content-submit').count(), 1);
const { requests: requestTable } = await import('../../apps/server/src/db/schema.ts');
assert.equal(
runtime.service.db.select().from(requestTable).all().length,
0,
'클립보드 읽기만으로 요청을 보내면 안 됨',
);
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
await receiver.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
const cardBox = await receiver
.locator('.incoming-card')
.filter({ hasText: '수락' })
.boundingBox();
assert(
cardBox && cardBox.y >= 0 && cardBox.y + cardBox.height <= 844,
'스크롤 뒤 수신 요청이 뷰포트 안에 있어야 함',
);
assert.equal(
await receiver
.locator('.consent-card')
.evaluate((node) => getComputedStyle(node).backgroundColor),
'rgb(255, 255, 255)',
);
assert(Math.abs(cardBox.y + cardBox.height / 2 - 844 / 2) < 2, '수락 팝업은 화면 중앙');
await receiver.screenshot({ path: 'tests/e2e/consent-mobile.png', fullPage: true });
await sender.getByText('상대 기기의 수락을 기다립니다.', { exact: true }).waitFor();
let releaseSnapshot, notifyCaptured;
const captured = new Promise((resolve) => {
notifyCaptured = resolve;
});
const held = new Promise((resolve) => {
releaseSnapshot = resolve;
});
let heldOnce = false;
await sender.route('**/api/device/snapshot', async (route) => {
if (heldOnce || route.request().method() !== 'GET') return route.continue();
heldOnce = true;
const response = await route.fetch();
const oldSnapshot = await response.json();
assert(oldSnapshot.requests.some((r) => r.status === 'pending'));
notifyCaptured();
await held;
await route.fulfill({ response, json: oldSnapshot });
});
await sender.evaluate(() => window.dispatchEvent(new Event('focus')));
await Promise.race([
captured,
new Promise((_, reject) =>
setTimeout(() => reject(Error('지연 조회가 시작되지 않았습니다.')), 10000),
),
]);
assert(!(await receiver.locator('body').innerText()).includes(secret));
const openedAutomatically = receiver.waitForEvent('popup');
await receiver.getByRole('button', { name: '수락', exact: true }).click();
releaseSnapshot();
await sender
.getByRole('button', { name: '전송 취소', exact: false })
.waitFor({ state: 'hidden' });
const external = await openedAutomatically;
await external.waitForURL(secret);
assert.equal(await external.evaluate(() => window.opener), null);
await external.close();
assert.equal(await receiver.locator('.arrival-card').count(), 0);
assert.equal(await receiver.locator('.item-card').count(), 0);
assert.equal(await receiver.getByRole('button', { name: /이번 기기/ }).count(), 0);
assert.equal(await receiver.evaluate(() => window.__hopXss), undefined);
const received = await receiver.evaluate(
async () => (await (await fetch('/api/device/items')).json()).items,
);
assert(received.find((item) => item.url === secret)?.read, '자동 열기 후 읽음 표시');
const cancelCountBefore = runtime.service.db.select().from(requestTable).all().length;
await sender.locator('.peer-card').first().click();
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
await sender.locator('.link-send').filter({ hasText: '전송 취소' }).waitFor();
await sender.locator('.link-send').filter({ hasText: '전송 취소' }).click();
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor({ state: 'hidden' });
assert.equal(runtime.service.db.select().from(requestTable).all().length, cancelCountBefore + 1);
assert.equal(runtime.service.db.select().from(requestTable).all().at(-1).status, 'cancelled');
await sender.reload();
await sender.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor();
await sender.evaluate(() => {
window.__hopClipboardDenied = true;
});
await sender.locator('.peer-card').first().click();
await sender.getByRole('button', { name: '파일 선택', exact: true }).waitFor();
assert.equal(await sender.locator('.clipboard-link-card').count(), 0);
const fileBytes = Buffer.from('브라우저 파일 전달 검증');
const dragged = await sender.evaluateHandle(() => {
const data = new DataTransfer();
data.items.add(new File(['브라우저 파일 전달 검증'], 'browser.txt', { type: 'text/plain' }));
return data;
});
await sender.locator('.unified-composer').dispatchEvent('drop', { dataTransfer: dragged });
await dragged.dispose();
await sender.locator('.selected-files').getByText('browser.txt', { exact: true }).waitFor();
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
await receiver.getByRole('button', { name: '수락', exact: true }).waitFor();
const downloaded = receiver.waitForEvent('download');
await receiver.getByRole('button', { name: '수락', exact: true }).click();
const download = await downloaded;
assert.equal(download.suggestedFilename(), 'browser.txt');
const { readFile } = await import('node:fs/promises');
assert.deepEqual(await readFile(await download.path()), fileBytes);
assert.equal(await receiver.locator('.arrival-card, .item-card').count(), 0);
assert.equal(await sender.locator('.active-transfers, .upload-status').count(), 0);
let uploadStarted, releaseUpload;
const uploadCaptured = new Promise((resolve) => {
uploadStarted = resolve;
});
const uploadHeld = new Promise((resolve) => {
releaseUpload = resolve;
});
await sender.route('**/api/uploads?*', async (route) => {
uploadStarted();
await uploadHeld;
try {
await route.abort('aborted');
} catch {}
});
await sender.locator('.peer-card').first().click();
await sender
.locator('input[type=file]')
.setInputFiles({ name: 'browser.txt', mimeType: 'text/plain', buffer: fileBytes });
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
await receiver.getByRole('button', { name: '수락', exact: true }).click();
await Promise.race([
uploadCaptured,
new Promise((_, reject) => setTimeout(() => reject(Error('업로드 시작 대기 실패')), 10000)),
]);
await sender.locator('.send-button').filter({ hasText: '전송 취소' }).click();
releaseUpload();
await receiver.locator('.receiving-card').waitFor({ state: 'hidden' });
await sender.locator('.send-button').filter({ hasText: '전송하기' }).waitFor();
await sender.unroute('**/api/uploads?*');
assert.equal(
await sender.getByRole('alert').count(),
0,
'사용자 취소를 업로드 오류로 표시하지 않음',
);
await sender.getByRole('button', { name: '전송 화면 닫기' }).click();
assert.equal(
await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor),
'rgb(0, 0, 0)',
);
await sender.screenshot({ path: 'tests/e2e/desktop.png', fullPage: true });
await sender.getByRole('button', { name: '라이트 테마로 전환', exact: true }).click();
assert.equal(
await sender.evaluate(() => getComputedStyle(document.documentElement).backgroundColor),
'rgb(250, 250, 250)',
);
await sender.screenshot({ path: 'tests/e2e/light-desktop.png', fullPage: true });
await sender.getByRole('button', { name: '다크 테마로 전환', exact: true }).click();
await receiver.screenshot({ path: 'tests/e2e/mobile.png', fullPage: true });
assert.equal(
await receiver.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
true,
'모바일 가로 넘침',
);
const second = await a.newPage();
await second.goto(url);
await second.locator('.device-name').filter({ hasText: '보내는 Mac' }).waitFor();
await sender.close();
await receiver.getByRole('button', { name: 'hop. 홈', exact: true }).click();
await receiver.locator('.peer-card').filter({ hasText: '보내는 Mac' }).waitFor();
const c = await browser.newContext({ viewport: { width: 1366, height: 900 } }),
urlReceiver = await c.newPage();
urlReceiver.on('pageerror', (error) => errors.push(error.message));
await urlReceiver.goto(url);
await urlReceiver.locator('.device-name').waitFor();
const receiverCode = (await urlReceiver.locator('.footer-device-code').innerText()).trim();
await urlReceiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await urlReceiver.getByRole('textbox', { name: '연결 코드' }).fill(code);
await urlReceiver.getByRole('button', { name: '참여', exact: true }).click();
await urlReceiver.locator('.room-leave').waitFor();
await urlReceiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
await second.locator('.peer-card').filter({ hasText: receiverCode }).waitFor();
await second.evaluate(() => {
window.__hopClipboard = '일반 텍스트는 전송하지 않습니다.';
});
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
await second.getByRole('button', { name: '파일 선택', exact: true }).waitFor();
assert.equal(
await second.locator('.clipboard-link-card').count(),
0,
'일반 텍스트를 링크로 제안하면 안 됨',
);
await second.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
const urls = ['https://example.com/path?x=1', 'http://192.168.1.10/private'];
await second.getByRole('textbox', { name: '보낼 URL' }).fill(urls.join('\n'));
await second.getByRole('button', { name: '전송하기', exact: true }).click();
await urlReceiver.getByRole('button', { name: '수락', exact: true }).waitFor();
assert(!(await urlReceiver.locator('body').innerText()).includes(urls[0]));
await c.route('https://example.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<h1>수신 링크</h1>' }),
);
await c.route('http://192.168.1.10/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<h1>수신 링크 2</h1>' }),
);
const opened = [];
urlReceiver.on('popup', (page) => opened.push(page));
await urlReceiver.getByRole('button', { name: '수락', exact: true }).click();
await second
.getByRole('button', { name: '전송 취소', exact: false })
.waitFor({ state: 'hidden' });
assert.equal(opened.length, 2);
await Promise.all(
opened.map((page) => page.waitForURL((address) => urls.includes(address.href))),
);
for (const address of urls) {
const page = opened.find((page) => page.url() === address);
assert(page, `자동으로 열린 링크: ${address}`);
assert.equal(await page.evaluate(() => window.opener), null);
await page.close();
}
await urlReceiver.evaluate(() => {
window.__nativeOpen = window.open;
window.open = () => null;
});
await second.locator('.peer-card').filter({ hasText: receiverCode }).click();
await second.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await second.getByRole('textbox', { name: '보낼 URL' }).fill('https://example.com/blocked');
await second.getByRole('button', { name: '전송하기', exact: true }).click();
await urlReceiver.getByRole('button', { name: '수락', exact: true }).click();
await urlReceiver.locator('.delivery-blocked').waitFor();
assert.equal(c.pages().length, 1);
await urlReceiver.evaluate(() => {
window.open = window.__nativeOpen;
});
const manuallyOpened = urlReceiver.waitForEvent('popup');
await urlReceiver
.locator('.delivery-blocked')
.getByRole('link', { name: /링크 열기/ })
.click();
const fallbackPage = await manuallyOpened;
await fallbackPage.waitForURL('https://example.com/blocked');
await fallbackPage.close();
await urlReceiver.locator('.delivery-blocked').waitFor({ state: 'hidden' });
await urlReceiver.reload();
await urlReceiver.locator('.device-name').waitFor();
assert.equal(c.pages().length, 1, '재조회나 새로고침으로 재실행하지 않음');
assert.equal(await second.getByRole('alert').count(), 0, '정상 전송이 요청 제한에 걸리면 안 됨');
const oldDevice = await second.locator('.device-name').innerText();
const renewed = second.waitForResponse(
(response) =>
new URL(response.url()).pathname === '/api/guest/session' &&
response.request().method() === 'POST' &&
response.status() === 200,
);
runtime.service.cfg.sessionSecret = 'rotated-test-secret-'.repeat(3);
for (const socket of runtime.io.sockets.sockets.values()) socket.conn.close();
await renewed;
await second.locator('.app-shell[data-online="true"]').waitFor();
assert.equal(await second.locator('.device-name').innerText(), oldDevice);
await second.locator('.device-name').click();
await second.getByRole('textbox', { name: '기기 이름' }).fill('재연결된 Mac');
await second.getByRole('button', { name: '이름 저장' }).click();
await second.locator('.device-name').filter({ hasText: '재연결된 Mac' }).waitFor();
assert.deepEqual(errors, []);
console.log(
'PASS: 두 브라우저 QR·코드 연결, 수락 전 본문 비공개, 클립보드 URL·복수 URL 전달과 일반 텍스트 무시, 수락 후 자동 새 탭 URL 열기, opener 격리, 읽음 동기화, 이름 유지, 다중 탭·CSRF 재연결 복구, 390px 가로 넘침 없음, 실제 파일 업로드·자동 다운로드, 수락 중 지연된 과거 조회에도 전달 완료, 스크롤 뒤 중앙의 고대비 수락 팝업, 검정/라이트 전환, 버튼 자리의 전송 취소·익명 내역 숨김·새로고침 중복 실행 방지.',
);
} catch (error) {
console.error(error);
console.error(requestCounts);
const { batches } = await import('../../apps/server/src/db/schema.ts');
console.error(runtime.service.db.select({ status: batches.status }).from(batches).all());
for (const context of browser?.contexts() ?? [])
for (const page of context.pages())
console.error((await page.locator('body').innerText()).slice(0, 4000));
throw error;
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}

233
tests/e2e/phase3.mjs Normal file
View File

@@ -0,0 +1,233 @@
import assert from 'node:assert/strict';
import { chromium } from '@playwright/test';
import { mkdtemp, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { loadConfig } from '../../apps/server/src/config.ts';
import { createApp } from '../../apps/server/src/app.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-account-e2e-')),
mails = [];
const runtime = await createApp(
{
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
PAIR_REQUESTS_PER_MINUTE: '10',
GUEST_REQUESTS_PER_MINUTE: '30',
IP_REQUESTS_PER_MINUTE: '60',
REGISTRATION_MODE: 'open',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
},
{
mailer: async (mail) => {
mails.push(mail);
},
},
);
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const { port } = runtime.app.server.address(),
url = `http://127.0.0.1:${port}`;
runtime.setDevOrigin(url);
runtime.service.cfg.appUrl = url;
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const a = await browser.newContext({ viewport: { width: 1440, height: 1000 } }),
b = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
await b.route('https://example.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><title>Account URL</title><h1>계정 수신 링크</h1>',
}),
);
const sender = await a.newPage(),
receiver = await b.newPage();
const errors = [];
for (const page of [sender, receiver]) page.on('pageerror', (e) => errors.push(e.message));
async function register(page, email, name) {
await page.goto(url);
await page.locator('.device-name').waitFor();
const deviceName = await page.locator('.footer-device-name').innerText();
await page.locator('.login-button').click();
await page.getByRole('button', { name: '이메일로 가입', exact: true }).click();
const auth = page.locator('.account-auth');
await auth.getByLabel('이메일', { exact: true }).fill(email);
await auth.getByLabel('표시 이름', { exact: true }).fill(name);
await auth.getByLabel('비밀번호', { exact: true }).fill('1234');
await auth.getByLabel('비밀번호 확인', { exact: true }).fill('1234');
await auth.getByRole('button', { name: '가입하고 인증 메일 받기' }).click();
await auth.locator('.auth-message').waitFor();
const token = mails.find((m) => m.to === email).text.match(/#verify=([A-Za-z0-9_-]+)/)[1];
await page.goto(`${url}/#verify=${token}`);
await page.getByRole('button', { name: '이메일 인증 완료하기' }).click();
await auth.getByRole('button', { name: '로그인', exact: true }).waitFor();
await auth.getByLabel('이메일', { exact: true }).fill(email);
await auth.getByLabel('비밀번호', { exact: true }).fill('1234');
await auth.getByRole('button', { name: '로그인', exact: true }).click();
await page.locator('.account-workspace').waitFor();
return await page.locator('.footer-device-name').innerText();
}
const senderName = await register(sender, 'alice@example.com', 'Alice');
await register(receiver, 'bob@example.com', 'Bob');
async function navigate(page, view) {
if (view === '기기') return page.getByRole('button', { name: 'hop. 홈', exact: true }).click();
await page.locator('.login-button').click();
await page
.getByRole('tab', { name: view === '받은 항목' ? '받은 항목' : '임시 저장', exact: true })
.click();
}
await navigate(sender, '기기');
await navigate(receiver, '기기');
assert.equal(await sender.locator('.hero').count(), 0);
await sender.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await sender.getByRole('button', { name: '연결 공간 만들기' }).click();
const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, '');
await sender.getByRole('button', { name: '대화상자 닫기' }).click();
await receiver.getByRole('button', { name: 'QR · 코드로 연결', exact: true }).click();
await receiver.getByRole('textbox', { name: '연결 코드' }).fill(code);
await receiver.getByRole('button', { name: '참여', exact: true }).click();
await receiver.locator('.room-leave').waitFor();
await receiver.getByRole('dialog', { name: 'QR · 코드로 연결' }).waitFor({ state: 'hidden' });
await sender.locator('.peer-card').first().click();
await sender.getByRole('button', { name: '대신 링크 붙여넣기', exact: true }).click();
await sender.getByLabel('보낼 URL').fill('https://example.com/received');
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
const automaticallyOpened = receiver.waitForEvent('popup');
await receiver.getByRole('button', { name: '수락', exact: true }).click();
const receivedPage = await automaticallyOpened;
await receivedPage.waitForURL('https://example.com/received');
await receivedPage.close();
await navigate(receiver, '받은 항목');
await receiver
.locator('.item-body')
.filter({ hasText: 'https://example.com/received' })
.waitFor();
assert.match(await receiver.locator('.dated-items h2').innerText(), /년.*월.*일 받은 항목/);
assert.equal(await receiver.getByRole('button', { name: /이번 기기/ }).count(), 0);
await navigate(sender, '보관함');
await sender.getByRole('button', { name: '보관함에 담기', exact: true }).click();
await sender
.getByRole('dialog', { name: '보관함에 담기' })
.getByRole('button', { name: '링크 저장', exact: true })
.click();
assert.equal(
await sender.getByRole('dialog', { name: '보관함에 담기' }).locator('input[type=file]').count(),
0,
);
await sender.getByLabel('한 줄 메모', { exact: true }).fill('나중에 읽을 글');
await sender.getByLabel('보낼 URL').fill('https://example.com/saved');
await sender.getByRole('button', { name: '저장하기', exact: true }).click();
await sender.locator('.item-card').filter({ hasText: '나중에 읽을 글' }).waitFor();
await sender.getByRole('button', { name: '메모 수정', exact: true }).click();
await sender.getByLabel('한 줄 메모 수정').fill('업무 링크');
await sender.locator('.note-editor').getByRole('button', { name: '저장', exact: true }).click();
await sender.locator('.item-note').filter({ hasText: '업무 링크' }).waitFor();
await navigate(sender, '기기');
assert.equal(await sender.locator('.footer-device-name').innerText(), senderName);
await sender.locator('.peer-card').first().click();
const beforeSource = await sender.locator('.send-dialog').boundingBox();
const requestsBeforeSelection = await sender.evaluate(
async () => (await (await fetch('/api/transfer-requests')).json()).requests.length,
);
await sender.getByRole('button', { name: '보관함에서 가져오기', exact: true }).click();
await sender.locator('.send-dialog .vault-choice').filter({ hasText: '업무 링크' }).click();
const afterSource = await sender.locator('.send-dialog').boundingBox();
assert.equal(afterSource.height, beforeSource.height);
assert.equal(afterSource.width, beforeSource.width);
assert.equal(await sender.locator('.content-submit').count(), 1);
assert.equal(
await sender.evaluate(
async () => (await (await fetch('/api/transfer-requests')).json()).requests.length,
),
requestsBeforeSelection,
'보관함 선택만으로 전송하지 않음',
);
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
const storedLinkOpened = receiver.waitForEvent('popup');
await receiver.getByRole('button', { name: '수락', exact: true }).click();
const storedLinkPage = await storedLinkOpened;
await storedLinkPage.waitForURL('https://example.com/saved');
await storedLinkPage.close();
await receiver.locator('.item-body').filter({ hasText: 'https://example.com/saved' }).waitFor();
await navigate(sender, '보관함');
await sender.getByRole('button', { name: '보관함에 담기', exact: true }).click();
await sender
.getByRole('dialog', { name: '보관함에 담기' })
.getByRole('button', { name: '파일 저장', exact: true })
.click();
assert.equal(
await sender.getByRole('dialog', { name: '보관함에 담기' }).getByLabel('보낼 URL').count(),
0,
);
await sender.getByLabel('한 줄 메모', { exact: true }).fill('업무 자료');
const bytes = Buffer.from('브라우저 보관함 파일');
await sender
.locator('input[type=file]')
.setInputFiles({ name: 'saved.txt', mimeType: 'text/plain', buffer: bytes });
await sender.getByRole('button', { name: '저장하기', exact: true }).click();
await sender.locator('.item-card').filter({ hasText: 'saved.txt' }).waitFor();
const uploads = [];
sender.on('request', (request) => {
if (new URL(request.url()).pathname === '/api/uploads') uploads.push(request.url());
});
await navigate(sender, '기기');
await sender.locator('.peer-card').first().click();
await sender.getByRole('button', { name: '보관함에서 가져오기', exact: true }).click();
await sender.locator('.send-dialog .vault-choice').filter({ hasText: '업무 자료' }).click();
await sender.getByRole('button', { name: '전송하기', exact: true }).click();
const downloaded = receiver.waitForEvent('download');
void downloaded.catch(() => {});
await receiver.getByRole('button', { name: '수락', exact: true }).click();
const download = await downloaded;
assert.equal(download.suggestedFilename(), 'saved.txt');
const { readFile } = await import('node:fs/promises');
assert.deepEqual(await readFile(await download.path()), bytes);
assert.equal(uploads.length, 0, '저장한 파일을 브라우저에서 재업로드하지 않음');
await receiver.locator('.item-card').filter({ hasText: 'saved.txt' }).waitFor();
await navigate(sender, '보관함');
const fileRow = sender.locator('.item-card').filter({ hasText: 'saved.txt' });
await fileRow.getByRole('button', { name: '계정 목록에서 삭제' }).click();
await fileRow.waitFor({ state: 'hidden' });
await sender.getByRole('button', { name: '실행 취소', exact: true }).click();
await fileRow.waitFor();
await sender.screenshot({ path: 'tests/e2e/account-desktop.png', fullPage: true });
await receiver.screenshot({ path: 'tests/e2e/account-mobile.png', fullPage: true });
assert.equal(
await receiver.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
const saved = await sender.evaluate(async () => (await (await fetch('/api/vault')).json()).items);
assert(saved.every((item) => item.expiresAt - item.createdAt <= 7 * 86400000));
await receiver.getByRole('button', { name: '로그아웃', exact: true }).click();
await receiver.locator('.account-auth').waitFor();
assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved'));
assert.deepEqual(errors, []);
console.log(
'PASS: 1234 가입·인증·로그인, 로그인 기기 수신의 날짜별 기록, 직접 링크/파일 저장과 메모 수정, 보관함 자료의 수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료, 삭제/복원, 로그아웃 권한 격리, 모바일 화면. 실제 Resend 발송은 포함하지 않음.',
);
} catch (error) {
console.error(error);
for (const context of browser?.contexts() ?? [])
for (const page of context.pages())
console.error((await page.locator('body').innerText()).slice(0, 4000));
throw error;
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}

9
tsconfig.base.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}