commit f7ee8cb9237864e915b1024d96e8a9055e544a87 Author: zenn Date: Wed Jun 10 11:48:06 2026 +0900 초기 버전 v1.0.0 구현 개인 NAS용 생존 확인 웹앱의 기본 기능, Docker 배포 설정, 설치 문서를 추가합니다. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..04f4b3a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +data +.git +.gitignore +README.md +.cursor +*.sqlite +*.sqlite-journal +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8510bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +data/ +*.sqlite +*.sqlite-journal +.DS_Store +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93be0a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine + +WORKDIR /app + +RUN apk add --no-cache tini + +COPY package.json ./ +RUN npm install --omit=dev + +COPY . . + +RUN chmod +x /app/scripts/*.sh \ + && mkdir -p /data + +ENV NODE_ENV=production \ + PORT=40100 \ + CONFIG_PATH=/app/config/default.json \ + DATABASE_PATH=/data/keeper.sqlite \ + TRIGGER_SCRIPT=/scripts/cleanup.sh \ + COLLECTION_PATH=/home/zenn/Collection + +EXPOSE 40100 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c2460e --- /dev/null +++ b/README.md @@ -0,0 +1,403 @@ +# Collection Keeper + +개인 NAS 사용자를 위한 초경량 생존 확인 웹앱입니다. + +- 현재 버전: `v1.0.0` +- Git 저장소: `https://git.sori.studio/zenn/collection.sori.studio.git` +- 상세 설치 문서: [`docs/DEPLOY.md`](docs/DEPLOY.md) + +가끔 방문해서 중앙 버튼을 누르면 확인 시간이 갱신되고, 일정 기간 동안 아무도 누르지 않으면 사용자가 지정한 스크립트가 실행됩니다. + +Collection Keeper는 Collection 폴더를 직접 삭제하지 않습니다. + +## 주요 기능 + +- 중앙 버튼 한 개로 생존 확인 +- SQLite 기반 통계 및 방문 흔적 저장 +- 1초 쿨다운 스팸 방지 +- 랜덤 이모지 클릭 효과 +- 배경 이미지 교체 및 랜덤 선택 +- threshold 초과 시 사용자 스크립트 실행 + +## 로컬 실행 가이드 + +개발하거나 먼저 동작을 확인할 때는 로컬에서 실행합니다. + +필요한 것: + +- Node.js 20 이상 +- npm + +설치: + +```bash +npm install +``` + +실행: + +```bash +npm start +``` + +브라우저에서 접속: + +```text +http://localhost:40100 +``` + +개발 중 파일 변경을 감지해서 다시 실행하려면: + +```bash +npm run dev +``` + +로컬 실행 시 SQLite 파일은 기본적으로 `data/keeper.sqlite`에 생성됩니다. + +## Docker 실행 가이드 + +Docker로 한 번에 빌드하고 실행합니다. + +필요한 것: + +- Docker +- Docker Compose + +실행: + +```bash +docker compose up -d --build +``` + +브라우저에서 접속: + +```text +http://localhost:40100 +``` + +상태 확인: + +```bash +docker compose ps +``` + +로그 확인: + +```bash +docker compose logs -f +``` + +중지: + +```bash +docker compose down +``` + +다시 시작: + +```bash +docker compose restart +``` + +컨테이너와 네트워크를 중지해도 `keeper-data` 볼륨에 SQLite 데이터가 남습니다. + +## 상용/NAS 설치 가이드 + +NAS에서 오래 켜둘 때는 Docker Compose 실행을 권장합니다. + +1. 프로젝트 폴더를 NAS에 둡니다. + +```bash +cd /volume1/docker/collection-keeper +``` + +2. 설정을 확인합니다. + +```bash +vi config/default.json +``` + +자주 바꾸는 항목: + +- `threshold_days`: 며칠 동안 버튼을 누르지 않으면 스크립트를 실행할지 +- `cooldown_seconds`: 생존 확인으로 인정되는 최소 클릭 간격 +- `trigger_script`: 실행할 사용자 스크립트 경로 +- `background`: `none`, 배경 파일명, 또는 `random` + +3. 사용자 스크립트를 수정합니다. + +```bash +vi scripts/cleanup.sh +chmod +x scripts/*.sh +``` + +4. `docker-compose.yml`의 NAS 경로를 확인합니다. + +기본값은 Synology 스타일 경로입니다. + +```yaml +- /home/zenn/Collection:/home/zenn/Collection +``` + +다른 경로를 쓴다면 왼쪽 경로를 실제 Collection 폴더로 바꿉니다. + +5. 실행합니다. + +```bash +docker compose up -d --build +``` + +6. 로그를 확인합니다. + +```bash +docker compose logs -f collection-keeper +``` + +7. NAS 주소로 접속합니다. + +```text +http://NAS_IP:40100 +``` + +예시: + +```text +http://192.168.0.10:40100 +``` + +## 업데이트 가이드 + +코드를 새 버전으로 바꾼 뒤 다시 빌드합니다. + +```bash +docker compose down +docker compose up -d --build +``` + +데이터를 유지하려면 `docker compose down -v`는 사용하지 마세요. `-v` 옵션은 SQLite 데이터가 들어 있는 Docker 볼륨까지 삭제합니다. + +## 백업 가이드 + +Docker Compose 기본 설정에서는 SQLite DB가 `keeper-data` 볼륨에 저장됩니다. + +볼륨 이름 확인: + +```bash +docker volume ls | grep keeper +``` + +로컬 실행에서는 `data/keeper.sqlite` 파일을 백업하면 됩니다. + +```bash +cp data/keeper.sqlite keeper.backup.sqlite +``` + +## 멀티 아키텍처 이미지 빌드 + +ARM64와 x86_64 이미지를 함께 빌드하려면 Docker Buildx를 사용합니다. + +```bash +docker buildx build --platform linux/amd64,linux/arm64 -t collection-keeper:latest . +``` + +## 문제 해결 + +### `EADDRINUSE: address already in use :::40100` + +이미 `40100` 포트를 쓰는 서버가 떠 있을 때 발생합니다. + +포트를 쓰는 프로세스 확인: + +```bash +lsof -nP -iTCP:40100 -sTCP:LISTEN +``` + +예시 출력에서 `PID`가 `30383`이라면 종료: + +```bash +kill 30383 +``` + +다시 실행: + +```bash +npm start +``` + +기존 서버를 끄고 싶지 않다면 다른 포트로 실행할 수도 있습니다. + +```bash +PORT=40101 npm start +``` + +이 경우 브라우저에서는 다음 주소로 접속합니다. + +```text +http://localhost:40101 +``` + +## 설정 + +기본 설정은 [`config/default.json`](config/default.json) 에 있습니다. + +| 항목 | 기본값 | 설명 | +| --- | --- | --- | +| `threshold_days` | `90` | 생존 확인 허용 기간 | +| `threshold_seconds` | `null` | 테스트용 초 단위 생존 확인 기간. 값이 있으면 `threshold_days`보다 우선합니다. | +| `cooldown_seconds` | `1` | 생존 확인 인정 쿨다운 | +| `check_interval_seconds` | `60` | 스크립트 검사 주기 | +| `database_path` | `./data/keeper.sqlite` | SQLite 경로 | +| `trigger_script` | `./scripts/cleanup.sh` | 조건 충족 시 실행할 스크립트 | +| `background` | `random` | `none`, 배경 파일명, 또는 `random` | +| `collection_path` | `/home/zenn/Collection` | Collection 폴더 경로 | +| `recent_traces_limit` | `5` | 최근 흔적 저장 조회 개수 | +| `reset_token` | `""` | 초기화 API 보호용 토큰. 비어 있으면 토큰 없이 초기화 가능합니다. | + +환경 변수로도 덮어쓸 수 있습니다. + +- `THRESHOLD_DAYS` +- `COOLDOWN_SECONDS` +- `CHECK_INTERVAL_SECONDS` +- `DATABASE_PATH` +- `TRIGGER_SCRIPT` +- `COLLECTION_PATH` +- `BACKGROUND` +- `PORT` +- `THRESHOLD_SECONDS` +- `RESET_TOKEN` + +## 화면 문구 커스터마이징 + +화면에 보이는 글, 클릭 이모지, 클릭 메시지는 [`public/custom.js`](public/custom.js) 한 파일에서 바꿉니다. + +코드를 몰라도 이 파일의 따옴표 안 문장과 이모지만 수정하면 됩니다. + +```js +window.COLLECTION_KEEPER_CUSTOM = { + keeperTextLines: [ + '가끔은 생각해.', + '', + '누군가 버튼을 누른다는 건', + '이곳을 기억하고 있다는 뜻일까,', + ], + + farewellTextLines: [ + '안녕.', + '', + '고마웠어.', + ], + + emojis: ['🐱', '🐰', '🌸', '✨'], + + clickMessages: [ + '기록은 남겨둘게.', + '아직 이곳을 기억하고 있어.', + ], +}; +``` + +- `keeperTextLines`: 오른쪽 아래에 표시되는 글입니다. 빈 줄은 `''`로 넣습니다. +- `farewellTextLines`: 남은 기간이 0이 되었을 때 표시되는 작별 인사입니다. +- `emojis`: 클릭할 때 기록되고 표시되는 이모지입니다. +- `clickMessages`: 클릭할 때 이모지 뒤에 붙는 랜덤 문장입니다. + +## 만료 테스트와 초기화 + +만료 화면을 빠르게 확인하려면 초 단위 타이머를 사용합니다. + +```bash +THRESHOLD_SECONDS=10 npm start +``` + +10초가 지나면 화면이 작별 인사로 전환되고, 더 이상 클릭해도 확인 시간이 갱신되지 않습니다. + +다시 처음 상태로 초기화: + +```bash +curl -X POST http://localhost:40100/api/reset +``` + +초기화 API에 토큰을 걸고 싶으면 `RESET_TOKEN`을 설정합니다. + +```bash +RESET_TOKEN=my-secret THRESHOLD_SECONDS=10 npm start +``` + +토큰이 있을 때 초기화: + +```bash +curl -X POST http://localhost:40100/api/reset \ + -H 'Content-Type: application/json' \ + -d '{"token":"my-secret"}' +``` + +## 배경 이미지 + +`assets/backgrounds/` 폴더에 이미지를 넣고 설정에서 지정합니다. 기본값은 `random`입니다. + +예시: + +```json +{ + "background": "bg01.jpg" +} +``` + +랜덤 사용: + +```json +{ + "background": "random" +} +``` + +배경 없이 사용: + +```json +{ + "background": "none" +} +``` + +## 사용자 스크립트 + +`scripts/` 폴더에 실행할 스크립트를 둡니다. + +예시: + +- `scripts/warning.sh` +- `scripts/encrypt.sh` +- `scripts/cleanup.sh` + +스크립트에는 다음 환경 변수가 전달됩니다. + +- `COLLECTION_PATH` +- `KEEPER_SCRIPT` + +## 안전 원칙 + +Collection Keeper는 다음 역할만 수행합니다. + +1. 마지막 확인 시간 관리 +2. 통계 관리 +3. 버튼 이벤트 처리 +4. 조건 충족 여부 판단 +5. 사용자 지정 스크립트 실행 + +파일 삭제, 암호화, 정리 같은 실제 작업은 반드시 사용자 스크립트에서 구현해야 합니다. + +## API + +- `GET /api/status` +- `POST /api/check` + +버튼 클릭은 반드시 `POST /api/check` 로 처리됩니다. 단순 방문만으로는 생존 확인이 갱신되지 않습니다. + +## NAS 볼륨 예시 + +```yaml +volumes: + - keeper-data:/data + - ./scripts:/scripts:ro + - /home/zenn/Collection:/home/zenn/Collection +``` diff --git a/assets/backgrounds/bg01.svg b/assets/backgrounds/bg01.svg new file mode 100644 index 0000000..d439f1e --- /dev/null +++ b/assets/backgrounds/bg01.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/backgrounds/bg02.svg b/assets/backgrounds/bg02.svg new file mode 100644 index 0000000..ddf2101 --- /dev/null +++ b/assets/backgrounds/bg02.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/backgrounds/bg03.svg b/assets/backgrounds/bg03.svg new file mode 100644 index 0000000..e326d4a --- /dev/null +++ b/assets/backgrounds/bg03.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/config/default.json b/config/default.json new file mode 100644 index 0000000..eba2b6e --- /dev/null +++ b/config/default.json @@ -0,0 +1,13 @@ +{ + "threshold_days": 90, + "threshold_seconds": null, + "cooldown_seconds": 1, + "check_interval_seconds": 60, + "database_path": "./data/keeper.sqlite", + "trigger_script": "./scripts/cleanup.sh", + "background": "random", + "collection_path": "/home/zenn/Collection", + "port": 40100, + "recent_traces_limit": 5, + "reset_token": "" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5a575a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + collection-keeper: + build: . + image: collection-keeper:latest + container_name: collection-keeper + restart: unless-stopped + ports: + - "40100:40100" + environment: + THRESHOLD_DAYS: 90 + # 만료 테스트가 필요하면 아래 값을 켜세요. 예: 10초 후 만료 + # THRESHOLD_SECONDS: 10 + COOLDOWN_SECONDS: 1 + CHECK_INTERVAL_SECONDS: 60 + PORT: 40100 + DATABASE_PATH: /data/keeper.sqlite + TRIGGER_SCRIPT: /scripts/cleanup.sh + COLLECTION_PATH: /home/zenn/Collection + BACKGROUND: random + # 초기화 API 보호가 필요하면 토큰을 지정하세요. + # RESET_TOKEN: change-me + volumes: + - keeper-data:/data + - ./config/default.json:/app/config/default.json:ro + - ./scripts:/scripts:ro + - ./assets/backgrounds:/app/assets/backgrounds:ro + - /home/zenn/Collection:/home/zenn/Collection + +volumes: + keeper-data: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..caf11d0 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,363 @@ +# Collection Keeper 설치 가이드 + +이 문서는 컴퓨터를 잘 모르는 분도 따라할 수 있도록 작성했습니다. + +프로젝트 폴더를 USB로 통째로 옮기지 않아도 됩니다. +**Git 저장소에서 받아서 설치**하는 방식을 권장합니다. + +- 저장소 주소: `https://git.sori.studio/zenn/collection.sori.studio.git` +- 현재 버전: `v1.0.0` + +--- + +## 1. 이 프로그램이 하는 일 + +Collection Keeper는 웹페이지에 들어와서 가끔 클릭해 주는 작은 서비스입니다. + +- 클릭하면 "아직 살아있음" 기록이 남습니다. +- 일정 기간 동안 아무도 클릭하지 않으면, 미리 준비한 스크립트가 실행됩니다. +- NAS에서는 `/home/zenn/Collection` 폴더를 삭제하도록 설정되어 있습니다. + +중요: + +- 웹앱이 직접 폴더를 지우지 않습니다. +- 실제 삭제는 `scripts/cleanup.sh`가 담당합니다. + +--- + +## 2. 준비물 + +### 개발 서버(맥/PC에서 먼저 테스트) + +- Node.js 20 이상 +- npm +- Git + +### 상용 서버(NAS) + +- Docker +- Docker Compose +- Git +- NAS에 `/home/zenn/Collection` 폴더 + +--- + +## 3. Git이란? + +Git은 프로그램 소스를 인터넷 저장소에 올려두고, 서버에서 내려받는 도구입니다. + +폴더를 직접 복사하는 것보다 좋은 점: + +- 나중에 업데이트가 쉽습니다. +- 어떤 버전을 썼는지 관리할 수 있습니다. +- 여러 서버에 같은 방식으로 설치할 수 있습니다. + +--- + +## 4. 개발 서버 설치 (맥/PC) + +### 4-1. Git 설치 확인 + +터미널을 열고 아래를 입력합니다. + +```bash +git --version +``` + +버전이 나오면 설치되어 있는 것입니다. + +### 4-2. 프로젝트 받기 + +원하는 폴더로 이동한 뒤 아래를 실행합니다. + +```bash +cd ~/Desktop +git clone https://git.sori.studio/zenn/collection.sori.studio.git +cd collection.sori.studio +``` + +### 4-3. 패키지 설치 + +```bash +npm install +``` + +### 4-4. 실행 + +```bash +npm start +``` + +브라우저에서 접속: + +```text +http://localhost:40100 +``` + +아래 명령으로 버전 정보가 나오면 정상입니다. + +```bash +curl http://localhost:40100/api/version +``` + +### 4-5. 개발 중 자동 재시작 + +```bash +npm run dev +``` + +--- + +## 5. 상용 서버 설치 (NAS + Docker) + +NAS에서는 보통 SSH로 접속해서 명령어를 실행합니다. + +### 5-1. NAS에 접속 + +예시: + +```bash +ssh zenn@NAS_IP +``` + +`NAS_IP`는 NAS의 실제 IP 주소로 바꿉니다. + +### 5-2. 설치 폴더 만들기 + +```bash +mkdir -p /home/zenn/docker +cd /home/zenn/docker +``` + +### 5-3. Git으로 프로젝트 받기 + +```bash +git clone https://git.sori.studio/zenn/collection.sori.studio.git +cd collection.sori.studio +``` + +### 5-4. 삭제 대상 폴더 확인 + +기본 삭제 경로: + +```text +/home/zenn/Collection +``` + +이 폴더가 실제로 존재하는지 확인합니다. + +```bash +ls -ld /home/zenn/Collection +``` + +### 5-5. 삭제 스크립트 확인 + +```bash +cat scripts/cleanup.sh +chmod +x scripts/*.sh +``` + +### 5-6. 설정 확인 + +기본 설정 파일: + +```text +config/default.json +``` + +자주 바꾸는 값: + +| 설정 | 기본값 | 설명 | +| --- | --- | --- | +| `threshold_days` | `90` | 며칠 동안 클릭이 없으면 스크립트 실행 | +| `cooldown_seconds` | `1` | 클릭 인정 최소 간격 | +| `collection_path` | `/home/zenn/Collection` | 삭제 대상 폴더 | +| `trigger_script` | `./scripts/cleanup.sh` | 실행할 스크립트 | +| `port` | `40100` | 웹 접속 포트 | + +Docker에서는 `docker-compose.yml`도 함께 확인합니다. + +### 5-7. Docker 실행 + +```bash +docker compose up -d --build +``` + +### 5-8. 실행 확인 + +```bash +docker compose ps +docker compose logs -f collection-keeper +``` + +브라우저 접속: + +```text +http://NAS_IP:40100 +``` + +--- + +## 6. 설정 항목 전체 + +### 6-1. `config/default.json` + +| 항목 | 기본값 | 설명 | +| --- | --- | --- | +| `threshold_days` | `90` | 생존 확인 허용 일수 | +| `threshold_seconds` | `null` | 테스트용 초 단위 타이머. 값이 있으면 일수보다 우선 | +| `cooldown_seconds` | `1` | 클릭 인정 쿨다운 | +| `check_interval_seconds` | `60` | 스크립트 검사 주기 | +| `database_path` | `./data/keeper.sqlite` | SQLite 경로 | +| `trigger_script` | `./scripts/cleanup.sh` | 만료 시 실행 스크립트 | +| `background` | `random` | 배경 이미지 설정 | +| `collection_path` | `/home/zenn/Collection` | Collection 폴더 | +| `port` | `40100` | 웹 포트 | +| `recent_traces_limit` | `5` | 최근 흔적 개수 | +| `reset_token` | `""` | 초기화 API 보호 토큰 | + +### 6-2. 화면 문구 (`public/custom.js`) + +| 항목 | 설명 | +| --- | --- | +| `keeperTextLines` | 오른쪽 아래 안내 문구 | +| `farewellTextLines` | 만료 후 중앙 작별 문구 | +| `emojis` | 클릭 이모지 목록 | +| `clickMessages` | 클릭 시 나오는 문장 목록 | + +코드를 몰라도 이 파일의 따옴표 안 문장만 바꾸면 됩니다. + +### 6-3. Docker 환경 변수 + +`docker-compose.yml`에서 바꿀 수 있습니다. + +- `THRESHOLD_DAYS` +- `THRESHOLD_SECONDS` +- `COOLDOWN_SECONDS` +- `CHECK_INTERVAL_SECONDS` +- `PORT` +- `DATABASE_PATH` +- `TRIGGER_SCRIPT` +- `COLLECTION_PATH` +- `BACKGROUND` +- `RESET_TOKEN` + +--- + +## 7. 업데이트 방법 + +이미 설치한 서버에서 새 버전을 받을 때: + +```bash +cd /home/zenn/docker/collection.sori.studio +git pull +docker compose down +docker compose up -d --build +``` + +개발 서버: + +```bash +cd collection.sori.studio +git pull +npm install +npm start +``` + +--- + +## 8. 테스트 방법 + +### 8-1. 짧은 타이머 테스트 + +10초 후 만료를 보고 싶다면: + +```bash +THRESHOLD_SECONDS=10 npm start +``` + +Docker: + +```yaml +THRESHOLD_SECONDS: 10 +``` + +### 8-2. 초기화 + +```bash +curl -X POST http://localhost:40100/api/reset +``` + +토큰을 켠 경우: + +```bash +curl -X POST http://localhost:40100/api/reset \ + -H 'Content-Type: application/json' \ + -d '{"token":"내-토큰"}' +``` + +주의: + +- 삭제 테스트 전에는 반드시 백업하세요. +- `cleanup.sh`는 실제로 `/home/zenn/Collection`을 삭제합니다. + +--- + +## 9. 자주 쓰는 명령어 + +### Docker + +```bash +docker compose up -d --build +docker compose ps +docker compose logs -f +docker compose restart +docker compose down +``` + +### Git + +```bash +git clone https://git.sori.studio/zenn/collection.sori.studio.git +git pull +git status +``` + +--- + +## 10. 문제 해결 + +### 포트가 이미 사용 중 + +```bash +lsof -nP -iTCP:40100 -sTCP:LISTEN +``` + +### 화면은 뜨는데 삭제가 안 됨 + +1. `threshold_days` 또는 `threshold_seconds` 확인 +2. `docker compose logs -f`에서 `[keeper] Threshold exceeded` 확인 +3. `scripts/cleanup.sh` 실행 권한 확인 +4. `/home/zenn/Collection` 볼륨 마운트 확인 + +### 버전 확인 + +API로 확인합니다. + +```bash +curl http://localhost:40100/api/version +``` + +--- + +## 11. 폴더를 통째로 옮겨도 되나? + +가능은 하지만 권장하지 않습니다. + +권장 방식: + +```bash +git clone https://git.sori.studio/zenn/collection.sori.studio.git +``` + +이 방식이 업데이트와 버전 관리에 훨씬 유리합니다. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ac7c376 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1255 @@ +{ + "name": "collection-keeper", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "collection-keeper", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "better-sqlite3": "^11.10.0", + "express": "^4.21.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c1e6558 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "collection-keeper", + "version": "1.0.0", + "description": "A lightweight survival check web app for personal NAS users", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "engines": { + "node": ">=20" + }, + "license": "MIT", + "dependencies": { + "better-sqlite3": "^11.10.0", + "express": "^4.21.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..4d1ad87 --- /dev/null +++ b/public/app.js @@ -0,0 +1,378 @@ +const custom = window.COLLECTION_KEEPER_CUSTOM ?? {}; +const activeEmojis = custom.emojis?.length ? custom.emojis : ['✨']; +const activeClickMessages = custom.clickMessages?.length ? custom.clickMessages : ['기록은 남겨둘게.']; +const KEEPER_TEXT_LINES = custom.keeperTextLines?.length + ? custom.keeperTextLines + : ['Collection Keeper']; +const FAREWELL_TEXT_LINES = custom.farewellTextLines?.length + ? custom.farewellTextLines + : ['안녕.', '고마웠어.']; + +const elements = { + backgroundLayer: document.getElementById('background-layer'), + effectLayer: document.getElementById('effect-layer'), + toast: document.getElementById('toast'), + keeperText: document.getElementById('keeper-text'), + farewellScreen: document.getElementById('farewell-screen'), + lastCheck: document.getElementById('last-check'), + remainingDays: document.getElementById('remaining-days'), + totalChecks: document.getElementById('total-checks'), + recentTraces: document.getElementById('recent-traces'), +}; + +let currentStatus = null; +let toastTimer = null; +let expired = false; +let expiryTransitionStarted = false; +let appliedBackground = null; +let particleCleanupTimer = null; +let expiryFinishTimer = null; + +function renderKeeperText() { + elements.keeperText.replaceChildren(); + + KEEPER_TEXT_LINES.forEach((line) => { + const row = document.createElement('p'); + row.textContent = line || '\u00A0'; + elements.keeperText.appendChild(row); + }); +} + +function renderFarewellText() { + elements.farewellScreen.replaceChildren(); + + FAREWELL_TEXT_LINES.forEach((line, index) => { + const row = document.createElement('p'); + row.textContent = line || '\u00A0'; + row.style.setProperty('--gather-delay', `${1.75 + index * 0.28}s`); + row.style.setProperty('--start-x', `${index % 2 === 0 ? -1 : 1}rem`); + row.style.setProperty('--start-y', `${1.4 + index * 0.3}rem`); + elements.farewellScreen.appendChild(row); + }); +} + +function randomEmoji() { + return activeEmojis[Math.floor(Math.random() * activeEmojis.length)]; +} + +function randomMessage() { + return activeClickMessages[Math.floor(Math.random() * activeClickMessages.length)]; +} + +function showToast(message) { + window.clearTimeout(toastTimer); + elements.toast.textContent = message; + elements.toast.classList.add('is-visible'); + + toastTimer = window.setTimeout(() => { + elements.toast.classList.remove('is-visible'); + }, 2200); +} + +function applyBackground(filename) { + if (!filename || appliedBackground === filename) { + return; + } + + appliedBackground = filename; + elements.backgroundLayer.style.backgroundImage = + `linear-gradient(rgba(255,255,255,0.18), rgba(255,255,255,0.18)), url('/assets/backgrounds/${filename}')`; +} + +function updateStatus(status) { + const wasInitialized = currentStatus !== null; + const wasExpired = expired || Boolean(currentStatus?.expired); + currentStatus = status; + renderTraces(status.recent_traces); + applyBackground(status.background); + + if (status.expired) { + if (wasInitialized && !wasExpired) { + startExpiryTransition(); + } else { + showExpiredImmediately(); + } + } else { + clearExpiryState(); + } + + renderLiveStats(); +} + +function renderLiveStats() { + if (!currentStatus) { + return; + } + + elements.lastCheck.textContent = formatRelativeTime(currentStatus.last_check_at); + elements.remainingDays.textContent = formatCountdown(currentStatus.deadline_at); + elements.totalChecks.textContent = `${Number(currentStatus.total_checks).toLocaleString('ko-KR')}회`; + + if (!expired && !expiryTransitionStarted && Date.now() >= new Date(currentStatus.deadline_at).getTime()) { + currentStatus.expired = true; + startExpiryTransition(); + } +} + +function startExpiryTransition() { + if (expiryTransitionStarted) { + expired = true; + return; + } + + expiryTransitionStarted = true; + expired = true; + prepareParticleExit(); + document.body.classList.add('is-expiring'); + + window.clearTimeout(expiryFinishTimer); + expiryFinishTimer = window.setTimeout(() => { + document.body.classList.remove('is-expiring'); + document.body.classList.add('is-expired'); + }, 4800); + + window.clearTimeout(particleCleanupTimer); + particleCleanupTimer = window.setTimeout(removeTextParticles, 2400); +} + +function showExpiredImmediately() { + expired = true; + expiryTransitionStarted = true; + window.clearTimeout(expiryFinishTimer); + window.clearTimeout(particleCleanupTimer); + removeTextParticles(); + document.body.classList.remove('is-expiring'); + document.body.classList.add('is-expired'); +} + +function clearExpiryState() { + if (!expired && !expiryTransitionStarted) { + return; + } + + expired = false; + expiryTransitionStarted = false; + document.body.classList.remove('is-expiring', 'is-expired'); + window.clearTimeout(expiryFinishTimer); + window.clearTimeout(particleCleanupTimer); + removeTextParticles(); +} + +function prepareParticleExit() { + removeTextParticles(); + + const targets = document.querySelectorAll( + '.keeper-text p, .text-stats div, .traces-panel h2, .recent-traces li' + ); + + targets.forEach((target) => { + createTextParticles(target); + }); +} + +function createTextParticles(target) { + const text = target.textContent ?? ''; + const visibleText = text.trim(); + const rect = target.getBoundingClientRect(); + + if (!visibleText || rect.width === 0 || rect.height === 0) { + return; + } + + const computedStyle = window.getComputedStyle(target); + const characters = Array.from(text); + const characterWidth = rect.width / Math.max(characters.length, 1); + + characters.forEach((character, index) => { + if (!character.trim()) { + return; + } + + const x = rect.left + characterWidth * (index + 0.5); + const y = rect.top + rect.height / 2; + createTextParticle(character, x, y, computedStyle); + }); +} + +function createTextParticle(character, x, y, computedStyle) { + const particle = document.createElement('span'); + particle.className = 'text-particle'; + particle.textContent = character; + particle.style.left = `${x}px`; + particle.style.top = `${y}px`; + particle.style.color = computedStyle.color; + particle.style.font = computedStyle.font; + particle.style.fontWeight = computedStyle.fontWeight; + particle.style.letterSpacing = computedStyle.letterSpacing; + particle.style.textShadow = computedStyle.textShadow; + + const angle = Math.random() * Math.PI * 2; + const distance = 60 + Math.random() * 180; + const dx = Math.cos(angle) * distance; + const dy = Math.sin(angle) * distance; + const rotation = -35 + Math.random() * 70; + + particle.style.setProperty('--particle-x', `${dx}px`); + particle.style.setProperty('--particle-y', `${dy}px`); + particle.style.setProperty('--particle-rotation', `${rotation}deg`); + particle.style.setProperty('--particle-delay', `${Math.random() * 0.45}s`); + + elements.effectLayer.appendChild(particle); +} + +function removeTextParticles() { + elements.effectLayer.querySelectorAll('.text-particle').forEach((particle) => { + particle.remove(); + }); +} + +function formatRelativeTime(isoString) { + const diffSeconds = Math.max(0, Math.floor((Date.now() - new Date(isoString).getTime()) / 1000)); + + if (diffSeconds < 60) { + return `${diffSeconds}초 전`; + } + + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) { + return `${diffMinutes}분 전`; + } + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) { + return `${diffHours}시간 전`; + } + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}일 전`; +} + +function formatCountdown(isoString) { + let remainingSeconds = Math.max( + 0, + Math.floor((new Date(isoString).getTime() - Date.now()) / 1000) + ); + + const days = Math.floor(remainingSeconds / 86400); + remainingSeconds %= 86400; + const hours = Math.floor(remainingSeconds / 3600); + remainingSeconds %= 3600; + const minutes = Math.floor(remainingSeconds / 60); + const seconds = remainingSeconds % 60; + + return `${days}일 ${hours}시간 ${minutes}분 ${seconds}초`; +} + +function renderTraces(traces) { + elements.recentTraces.innerHTML = ''; + const visibleTraces = traces.slice(0, 5); + + if (!visibleTraces.length) { + const item = document.createElement('li'); + item.className = 'empty'; + item.textContent = '아직 흔적이 없어요'; + elements.recentTraces.appendChild(item); + return; + } + + visibleTraces.forEach((trace) => { + const item = document.createElement('li'); + + const emoji = document.createElement('span'); + emoji.className = 'emoji'; + emoji.textContent = trace.emoji; + + const time = document.createElement('span'); + time.className = 'time'; + time.textContent = trace.time; + + item.appendChild(emoji); + item.appendChild(time); + elements.recentTraces.appendChild(item); + }); +} + +function spawnClickMessage(emoji, message) { + const node = document.createElement('div'); + node.className = 'click-message'; + node.textContent = `${emoji} ${message}`; + elements.effectLayer.appendChild(node); + + const { x, y } = getRandomMessagePosition(node); + node.style.left = `${x}px`; + node.style.top = `${y}px`; + + window.setTimeout(() => { + node.remove(); + }, 1800); +} + +function getRandomMessagePosition(node) { + const safeMargin = 32; + const rect = node.getBoundingClientRect(); + const width = rect.width || 240; + const height = rect.height || 40; + const minX = safeMargin + width / 2; + const maxX = Math.max(minX, window.innerWidth - safeMargin - width / 2); + const minY = safeMargin; + const maxY = Math.max(minY, window.innerHeight - safeMargin - height); + + return { + x: randomBetween(minX, maxX), + y: randomBetween(minY, maxY), + }; +} + +function randomBetween(min, max) { + return min + Math.random() * (max - min); +} + +async function loadStatus() { + const response = await fetch('/api/status'); + const status = await response.json(); + updateStatus(status); +} + +async function handleCheck() { + if (expired) { + return; + } + + const emoji = randomEmoji(); + const message = randomMessage(); + spawnClickMessage(emoji, message); + + try { + const response = await fetch('/api/check', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ emoji }), + }); + const result = await response.json(); + + updateStatus(result.status); + if (result.expired || result.status.expired) { + showToast('이미 시간이 지났어요.'); + return; + } + + showToast(result.accepted ? `${emoji} 방금 확인했어요.` : `${emoji} 잠시 후 다시 눌러주세요.`); + } catch (error) { + showToast('잠시 문제가 생겼어요. 다시 눌러주세요.'); + } +} + +document.addEventListener('click', handleCheck); +renderKeeperText(); +renderFarewellText(); +loadStatus().catch(() => { + showToast('상태를 불러오지 못했어요.'); +}); + +window.setInterval(renderLiveStats, 1000); + +window.setInterval(() => { + loadStatus().catch(() => {}); +}, 1000); diff --git a/public/custom.js b/public/custom.js new file mode 100644 index 0000000..30c6ec1 --- /dev/null +++ b/public/custom.js @@ -0,0 +1,82 @@ +// Collection Keeper 사용자 설정 파일 +// 이 파일만 수정하면 화면 문구, 클릭 이모지, 클릭 메시지를 바꿀 수 있습니다. + +window.COLLECTION_KEEPER_CUSTOM = { + // 오른쪽 아래에 표시할 글입니다. 줄을 추가하거나 지우면 화면에도 그대로 반영됩니다. + keeperTextLines: [ + '가끔은 생각해.', + '', + '누군가 버튼을 누른다는 건', + '이곳을 기억하고 있다는 뜻일까,', + '', + '아니면 그냥 지나가다 우연히 발견한 걸까,', + '', + '사실 어느 쪽이든 괜찮아,', + '', + '버튼이 눌리는 순간만큼은', + '이곳도 조금 살아있는 것 같으니까.', + ], + + // 남은 기간이 0이 되었을 때 화면 중앙에 표시할 작별 인사입니다. + farewellTextLines: [ + '안녕.', + '고마웠어.', + ], + + // 클릭할 때 기록되고 화면에 표시될 이모지입니다. + emojis: ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙'], + + // 클릭할 때 이모지 뒤에 함께 나올 문장입니다. + clickMessages: [ + '오늘은 조금 조용하네.', + '버튼 누르는 거 잊지 마.', + '여기까지 찾아왔구나.', + '컬렉션은 오늘도 무사해.', + '아직 아무 일도 없었어.', + '안녕. 오늘도 와줬네.', + '버튼을 눌러주면 조금 안심이 돼.', + '가끔은 아무도 오지 않는 날도 있어.', + '괜찮아. 천천히 둘러보다 가.', + '기억은 생각보다 오래 남아.', + '나는 계속 여기 있었어.', + '버튼 하나 정도는 눌러줄 수 있지?', + '이곳은 아직 정상적으로 작동 중이야.', + '이상 없음. 조금 심심한 것 빼고는.', + '언젠가는 전부 잊어버릴지도 몰라.', + '그래도 지금은 기억하고 있어.', + '최근에 누군가 다녀간 흔적이 남아있어.', + '그 흔적이 너였을까?', + '조금 늦게 와도 괜찮아.', + '오래 비워두지만 않으면 돼.', + '오늘은 어떤 하루였어?', + '별 의미 없는 질문이지만.', + '가끔은 버튼을 누르는 이유도 잊어버려.', + '그래도 누군가는 눌러주더라.', + '시간은 계속 흐르고 있어.', + '나는 그냥 지켜보고 있을 뿐이야.', + '여기는 조금 느리게 흘러가.', + '수집한 것들은 아직 그대로야.', + '잊지 않고 찾아와줘서 고마워.', + '오늘도 무사히 확인 완료.', + '다음에 또 보자.', + '가끔 내가 먼저 기억하는 건지 헷갈려.', + '버튼이 눌리면 여기 시간도 조금 움직여.', + '아무도 누르지 않으면 이곳은 잠이 들어.', + '오늘은 아직 아무도 다녀가지 않았어.', + '혹시 길을 잃고 들어온 거야?', + '괜찮아. 대부분 처음엔 그렇게 들어와.', + '여기까지 온 김에 버튼 하나만 부탁할게.', + '나는 기다리는 일에는 익숙해.', + '생각난 김에 눌러도 충분해.', + '잊어버리지 않았다면 그걸로 됐어.', + '오늘도 평화로워.', + '아직은 모든 것이 제자리에 있어.', + '이곳은 조용할수록 좋은 곳이야.', + '버튼 하나로 충분해.', + '네가 왔다는 것만으로도 의미가 있어.', + '오늘도 컬렉션을 지키고 있었어.', + '별일 없는 하루가 제일 좋은 법이야.', + '여기는 늘 같은 자리에 있어.', + '생각보다 오래 함께하고 있네.' + ], +}; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4af7234 --- /dev/null +++ b/public/index.html @@ -0,0 +1,47 @@ + + + + + + Collection Keeper + + + + + +
+ +
+
+ +
+ + + + +
+ + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..f17e06d --- /dev/null +++ b/public/styles.css @@ -0,0 +1,332 @@ +:root { + color-scheme: light; + --text: rgba(42, 42, 42, 0.78); + --muted: rgba(42, 42, 42, 0.5); + --strong: rgba(20, 20, 20, 0.82); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + font-family: "Apple SD Gothic Neo", "Noto Sans KR", sans-serif; + color: var(--text); +} + +body { + overflow: hidden; + cursor: pointer; + user-select: none; +} + +body.is-expired { + cursor: default; +} + +body.is-expiring { + cursor: default; +} + +.background-layer { + position: fixed; + inset: 0; + z-index: 0; + background: #fff; + background-size: cover; + background-position: center; + transition: background-image 0.6s ease; +} + +.effect-layer { + position: fixed; + inset: 0; + z-index: 3; + pointer-events: none; + overflow: hidden; +} + +.toast { + position: fixed; + top: 1.5rem; + right: 1.5rem; + z-index: 4; + max-width: min(24rem, calc(100vw - 3rem)); + padding: 0.75rem 0.95rem; + border-radius: 999px; + color: var(--strong); + font-size: 0.82rem; + line-height: 1.45; + background: rgba(255, 255, 255, 0.76); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + opacity: 0; + pointer-events: none; + transform: translateY(-0.4rem); + transition: opacity 0.2s ease, transform 0.2s ease; + backdrop-filter: blur(10px); +} + +.toast.is-visible { + opacity: 1; + transform: translateY(0); +} + +.app { + position: relative; + z-index: 1; + min-height: 100vh; +} + +.click-stage { + position: fixed; + inset: 0; + z-index: 1; +} + +.farewell-screen { + position: fixed; + inset: 0; + z-index: 2; + display: none; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--strong); + font-size: clamp(1.2rem, 3vw, 2rem); + line-height: 2; + letter-spacing: -0.03em; + text-align: center; + text-shadow: 0 1px 10px rgba(255, 255, 255, 0.9); +} + +.farewell-screen p { + margin: 0; + opacity: 0; + transform: translate(var(--start-x, 0), var(--start-y, 1.2rem)) scale(1.08); +} + +body.is-expiring .farewell-screen { + display: flex; + flex-direction: column; +} + +body.is-expiring .farewell-screen p { + animation: gather-in 2.8s cubic-bezier(0.16, 1, 0.3, 1) var(--gather-delay, 0s) forwards; +} + +body.is-expired .farewell-screen { + display: flex; + flex-direction: column; +} + +body.is-expired .farewell-screen p { + opacity: 1; + transform: translate(0, 0) scale(1); +} + +body.is-expiring .keeper-panel, +body.is-expiring .traces-panel { + opacity: 0; + pointer-events: none; + transition: opacity 0.45s ease; +} + +.text-particle { + position: fixed; + z-index: 4; + pointer-events: none; + line-height: 1; + animation: particle-disperse 1.65s cubic-bezier(0.2, 0.8, 0.2, 1) var(--particle-delay, 0s) forwards; + transform-origin: center; + will-change: opacity, transform, filter; +} + +body.is-expired .keeper-panel, +body.is-expired .traces-panel { + display: none; +} + +.keeper-panel, +.traces-panel { + position: fixed; + z-index: 2; + max-width: min(24rem, calc(100vw - 3rem)); + font-size: 0.78rem; + line-height: 1.9; + letter-spacing: -0.02em; + text-shadow: 0 1px 8px rgba(255, 255, 255, 0.85); +} + +.keeper-panel { + right: 2rem; + bottom: 2rem; + text-align: right; +} + +.keeper-text { + margin-bottom: 1.3rem; + color: var(--strong); +} + +.keeper-text p { + margin: 0; + white-space: pre-wrap; +} + +.text-stats { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.text-stats div { + display: flex; + justify-content: flex-end; + gap: 0.45rem; +} + +.text-stats span, +.traces-panel h2 { + color: var(--muted); + font-weight: 400; +} + +.text-stats strong { + color: var(--strong); + font-weight: 600; +} + +.traces-panel { + left: 2rem; + bottom: 2rem; +} + +.traces-panel h2 { + margin: 0 0 0.35rem; + font-size: 0.78rem; +} + +.recent-traces { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.12rem; +} + +.recent-traces li { + display: flex; + align-items: center; + gap: 0.45rem; + color: var(--text); +} + +.recent-traces .emoji { + width: 1.3rem; + font-size: 0.95rem; +} + +.recent-traces .time { + color: var(--muted); +} + +.recent-traces .empty { + color: var(--muted); +} + +.click-message { + position: absolute; + max-width: min(28rem, calc(100vw - 3rem)); + color: var(--strong); + font-size: clamp(0.92rem, 2vw, 1.1rem); + line-height: 1.5; + white-space: nowrap; + text-shadow: 0 1px 12px rgba(255, 255, 255, 0.95); + animation: message-fade 1.8s ease forwards; +} + +@keyframes message-fade { + 0% { + opacity: 0; + transform: translate(-50%, 0.75rem); + } + + 16% { + opacity: 1; + transform: translate(-50%, 0); + } + + 72% { + opacity: 1; + transform: translate(-50%, -0.25rem); + } + + 100% { + opacity: 0; + transform: translate(-50%, -1.25rem); + } +} + +@keyframes particle-disperse { + 0% { + opacity: 1; + filter: blur(0); + transform: translate(0, 0) rotate(0deg) scale(1); + } + + 55% { + opacity: 0.58; + filter: blur(1.2px); + } + + 100% { + opacity: 0; + filter: blur(5px); + transform: + translate(var(--particle-x, 3rem), var(--particle-y, -4rem)) + rotate(var(--particle-rotation, 6deg)) + scale(0.86); + } +} + +@keyframes gather-in { + 0% { + opacity: 0; + filter: blur(8px); + letter-spacing: 0.08em; + transform: translate(var(--start-x, 0), var(--start-y, 1.2rem)) scale(1.08); + } + + 55% { + opacity: 0.64; + filter: blur(2px); + } + + 100% { + opacity: 1; + filter: blur(0); + letter-spacing: -0.03em; + transform: translate(0, 0) scale(1); + } +} + +@media (max-width: 640px) { + .toast { + top: 1rem; + right: 1rem; + } + + .keeper-panel { + right: 1.25rem; + bottom: 1.25rem; + max-width: calc(100vw - 2.5rem); + } + + .traces-panel { + display: none; + } +} diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh new file mode 100755 index 0000000..ccd660f --- /dev/null +++ b/scripts/cleanup.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +TARGET="${COLLECTION_PATH:-/home/zenn/Collection}" + +case "$TARGET" in + /home/zenn/Collection) ;; + *) + echo "[collection-keeper] refused unsafe path: $TARGET" + exit 1 + ;; +esac + +if [ ! -e "$TARGET" ]; then + echo "[collection-keeper] target not found: $TARGET" + exit 0 +fi + +rm -rf -- "$TARGET" +echo "[collection-keeper] deleted: $TARGET" diff --git a/scripts/encrypt.sh b/scripts/encrypt.sh new file mode 100755 index 0000000..155e397 --- /dev/null +++ b/scripts/encrypt.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +echo "[collection-keeper] encrypt.sh example script" +echo "[collection-keeper] COLLECTION_PATH=${COLLECTION_PATH:-/home/zenn/Collection}" +echo "[collection-keeper] Encrypt sensitive files here." + +exit 0 diff --git a/scripts/warning.sh b/scripts/warning.sh new file mode 100755 index 0000000..4e94e9a --- /dev/null +++ b/scripts/warning.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +echo "[collection-keeper] warning.sh example script" +echo "[collection-keeper] COLLECTION_PATH=${COLLECTION_PATH:-/home/zenn/Collection}" +echo "[collection-keeper] Send a warning notification here." + +exit 0 diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..9216106 --- /dev/null +++ b/src/config.js @@ -0,0 +1,104 @@ +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'config', 'default.json'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function loadConfig() { + const configPath = process.env.CONFIG_PATH + ? path.resolve(process.env.CONFIG_PATH) + : DEFAULT_CONFIG_PATH; + + const fileConfig = fs.existsSync(configPath) ? readJson(configPath) : {}; + const thresholdSeconds = optionalNumber( + process.env.THRESHOLD_SECONDS ?? fileConfig.threshold_seconds + ); + + const config = { + threshold_days: Number(process.env.THRESHOLD_DAYS ?? fileConfig.threshold_days ?? 90), + threshold_seconds: thresholdSeconds, + cooldown_seconds: Number(process.env.COOLDOWN_SECONDS ?? fileConfig.cooldown_seconds ?? 1), + check_interval_seconds: Number( + process.env.CHECK_INTERVAL_SECONDS ?? fileConfig.check_interval_seconds ?? 60 + ), + database_path: process.env.DATABASE_PATH ?? fileConfig.database_path ?? './data/keeper.sqlite', + trigger_script: process.env.TRIGGER_SCRIPT ?? fileConfig.trigger_script ?? './scripts/cleanup.sh', + background: process.env.BACKGROUND ?? fileConfig.background ?? 'random', + collection_path: process.env.COLLECTION_PATH ?? fileConfig.collection_path ?? '/home/zenn/Collection', + port: Number(process.env.PORT ?? fileConfig.port ?? 40100), + recent_traces_limit: Number( + process.env.RECENT_TRACES_LIMIT ?? fileConfig.recent_traces_limit ?? 5 + ), + reset_token: process.env.RESET_TOKEN ?? fileConfig.reset_token ?? '', + root_dir: ROOT_DIR, + config_path: configPath, + }; + + config.database_path = resolvePath(config.database_path); + config.trigger_script = resolvePath(config.trigger_script); + + return config; +} + +function optionalNumber(value) { + if (value === undefined || value === null || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function resolvePath(targetPath) { + if (path.isAbsolute(targetPath)) { + return targetPath; + } + + return path.resolve(ROOT_DIR, targetPath); +} + +function listBackgrounds(rootDir) { + const backgroundsDir = path.join(rootDir, 'assets', 'backgrounds'); + + if (!fs.existsSync(backgroundsDir)) { + return []; + } + + return fs + .readdirSync(backgroundsDir) + .filter((file) => /\.(jpg|jpeg|png|webp|svg)$/i.test(file)) + .sort(); +} + +function pickBackground(config) { + if (!config.background || config.background === 'none') { + return null; + } + + const backgrounds = listBackgrounds(config.root_dir); + + if (backgrounds.length === 0) { + return null; + } + + if (config.background === 'random') { + return backgrounds[Math.floor(Math.random() * backgrounds.length)]; + } + + if (backgrounds.includes(config.background)) { + return config.background; + } + + return backgrounds[0]; +} + +module.exports = { + loadConfig, + listBackgrounds, + pickBackground, + resolvePath, +}; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..9bb323a --- /dev/null +++ b/src/db.js @@ -0,0 +1,283 @@ +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); + +const FALLBACK_EMOJIS = ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙']; + +function ensureDirectory(filePath) { + const directory = path.dirname(filePath); + fs.mkdirSync(directory, { recursive: true }); +} + +function createDatabase(databasePath) { + ensureDirectory(databasePath); + + const db = new Database(databasePath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.pragma('busy_timeout = 5000'); + + db.exec(` + CREATE TABLE IF NOT EXISTS state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_check_at TEXT NOT NULL, + total_checks INTEGER NOT NULL DEFAULT 0, + trigger_executed_for_check_at TEXT + ); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + emoji TEXT NOT NULL, + created_at TEXT NOT NULL, + accepted INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS trigger_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + executed_at TEXT NOT NULL, + script_path TEXT NOT NULL, + success INTEGER NOT NULL DEFAULT 0, + output TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC); + `); + + const existingState = db.prepare('SELECT id FROM state WHERE id = 1').get(); + + if (!existingState) { + const now = new Date().toISOString(); + db.prepare(` + INSERT INTO state (id, last_check_at, total_checks, trigger_executed_for_check_at) + VALUES (1, ?, 0, NULL) + `).run(now); + } + + return db; +} + +function randomEmoji() { + return FALLBACK_EMOJIS[Math.floor(Math.random() * FALLBACK_EMOJIS.length)]; +} + +function getStateRow(db) { + return db.prepare('SELECT * FROM state WHERE id = 1').get(); +} + +function getRecentEvents(db, limit) { + return db + .prepare(` + SELECT emoji, created_at, accepted + FROM events + ORDER BY id DESC + LIMIT ? + `) + .all(limit); +} + +function daysBetween(fromDate, toDate) { + const start = new Date(fromDate); + const end = new Date(toDate); + const diffMs = end.getTime() - start.getTime(); + return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24))); +} + +function thresholdMilliseconds(config) { + if (config.threshold_seconds) { + return config.threshold_seconds * 1000; + } + + return config.threshold_days * 24 * 60 * 60 * 1000; +} + +function deadlineFor(lastCheckAt, config) { + return new Date(new Date(lastCheckAt).getTime() + thresholdMilliseconds(config)); +} + +function isExpired(state, config, now = new Date()) { + return now.getTime() >= deadlineFor(state.last_check_at, config).getTime(); +} + +function buildStatus(db, config) { + const state = getStateRow(db); + const now = new Date(); + const lastCheckAt = new Date(state.last_check_at); + const elapsedDays = daysBetween(state.last_check_at, now.toISOString()); + const deadlineAt = deadlineFor(lastCheckAt, config); + const remainingMs = Math.max(0, deadlineAt.getTime() - now.getTime()); + const remainingDays = Math.floor(remainingMs / (24 * 60 * 60 * 1000)); + + return { + last_check_at: state.last_check_at, + deadline_at: deadlineAt.toISOString(), + expired: remainingMs <= 0, + last_check_display: formatDateTime(lastCheckAt), + elapsed_days: elapsedDays, + remaining_days: remainingDays, + total_checks: state.total_checks, + threshold_days: config.threshold_days, + threshold_seconds: config.threshold_seconds, + cooldown_seconds: config.cooldown_seconds, + collection_path: config.collection_path, + recent_traces: getRecentEvents(db, config.recent_traces_limit).map((event) => ({ + emoji: event.emoji, + time: formatTime(new Date(event.created_at)), + created_at: event.created_at, + accepted: Boolean(event.accepted), + })), + }; +} + +function formatDateTime(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +function formatTime(date) { + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${hours}:${minutes}:${seconds}`; +} + +function recordCheck(db, config, requestedEmoji) { + const emoji = + typeof requestedEmoji === 'string' && requestedEmoji.trim() + ? requestedEmoji.trim().slice(0, 16) + : randomEmoji(); + + const insertEvent = db.prepare(` + INSERT INTO events (emoji, created_at, accepted) + VALUES (?, ?, ?) + `); + + const updateState = db.prepare(` + UPDATE state + SET last_check_at = ?, + total_checks = total_checks + 1, + trigger_executed_for_check_at = NULL + WHERE id = 1 + `); + + let accepted = false; + let expired = false; + + const transaction = db.transaction(() => { + const now = new Date(); + const nowIso = now.toISOString(); + const state = getStateRow(db); + + if (isExpired(state, config, now)) { + expired = true; + return; + } + + const lastAcceptedAt = new Date(state.last_check_at); + const secondsSinceLastCheck = (now.getTime() - lastAcceptedAt.getTime()) / 1000; + accepted = secondsSinceLastCheck >= config.cooldown_seconds; + + insertEvent.run(emoji, nowIso, accepted ? 1 : 0); + + if (accepted) { + updateState.run(nowIso); + } + }); + + transaction(); + + if (expired) { + return { + accepted: false, + expired: true, + emoji, + message: '이미 시간이 지났어요.', + status: buildStatus(db, config), + }; + } + + const message = accepted + ? `${emoji} 방금 확인했어요!` + : `${emoji} 잠시 후 다시 눌러주세요.`; + + return { + accepted, + expired: false, + emoji, + message, + status: buildStatus(db, config), + }; +} + +function shouldRunTrigger(db, config) { + const state = getStateRow(db); + + if (!isExpired(state, config)) { + return false; + } + + if (state.trigger_executed_for_check_at === state.last_check_at) { + return false; + } + + return true; +} + +function markTriggerExecuted(db, scriptPath, success, output) { + const nowIso = new Date().toISOString(); + const state = getStateRow(db); + + const insertRun = db.prepare(` + INSERT INTO trigger_runs (executed_at, script_path, success, output) + VALUES (?, ?, ?, ?) + `); + + const updateState = db.prepare(` + UPDATE state + SET trigger_executed_for_check_at = ? + WHERE id = 1 + `); + + const transaction = db.transaction(() => { + insertRun.run(nowIso, scriptPath, success ? 1 : 0, output ?? ''); + updateState.run(state.last_check_at); + }); + + transaction(); +} + +function resetKeeper(db) { + const nowIso = new Date().toISOString(); + + const resetState = db.prepare(` + UPDATE state + SET last_check_at = ?, + total_checks = 0, + trigger_executed_for_check_at = NULL + WHERE id = 1 + `); + + const deleteEvents = db.prepare('DELETE FROM events'); + const deleteTriggerRuns = db.prepare('DELETE FROM trigger_runs'); + + const transaction = db.transaction(() => { + resetState.run(nowIso); + deleteEvents.run(); + deleteTriggerRuns.run(); + }); + + transaction(); +} + +module.exports = { + FALLBACK_EMOJIS, + createDatabase, + buildStatus, + recordCheck, + shouldRunTrigger, + markTriggerExecuted, + resetKeeper, +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..7336605 --- /dev/null +++ b/src/server.js @@ -0,0 +1,108 @@ +const express = require('express'); +const path = require('path'); +const { loadConfig, pickBackground } = require('./config'); +const { + createDatabase, + buildStatus, + recordCheck, + shouldRunTrigger, + markTriggerExecuted, + resetKeeper, +} = require('./db'); +const { runTriggerScript } = require('./trigger'); +const { getVersionInfo } = require('./version'); + +const config = loadConfig(); +const db = createDatabase(config.database_path); +const app = express(); + +let triggerRunning = false; +const sessionBackground = pickBackground(config); + +app.use(express.json()); +app.use(express.static(path.join(config.root_dir, 'public'))); +app.use('/assets', express.static(path.join(config.root_dir, 'assets'))); + +app.get('/api/version', (_req, res) => { + res.json(getVersionInfo()); +}); + +app.get('/api/status', (_req, res) => { + res.json({ + ...buildStatus(db, config), + ...getVersionInfo(), + background: sessionBackground, + }); +}); + +app.post('/api/check', (req, res) => { + const result = recordCheck(db, config, req.body?.emoji); + res.json(result); +}); + +app.post('/api/reset', (req, res) => { + if (!isResetAllowed(req)) { + res.status(403).json({ error: 'Reset token is invalid.' }); + return; + } + + resetKeeper(db); + res.json({ + ok: true, + status: buildStatus(db, config), + }); +}); + +app.get('*', (_req, res) => { + res.sendFile(path.join(config.root_dir, 'public', 'index.html')); +}); + +function isResetAllowed(req) { + if (!config.reset_token) { + return true; + } + + const token = req.body?.token ?? req.get('x-reset-token'); + return token === config.reset_token; +} + +async function evaluateTrigger() { + if (triggerRunning || !shouldRunTrigger(db, config)) { + return; + } + + triggerRunning = true; + + try { + console.log(`[keeper] Threshold exceeded. Running script: ${config.trigger_script}`); + const result = await runTriggerScript(config.trigger_script, config.collection_path); + markTriggerExecuted(db, config.trigger_script, result.success, result.output); + + if (result.success) { + console.log('[keeper] Trigger script completed successfully.'); + } else { + console.error('[keeper] Trigger script failed:', result.output); + } + } catch (error) { + markTriggerExecuted(db, config.trigger_script, false, error.message); + console.error('[keeper] Trigger execution error:', error.message); + } finally { + triggerRunning = false; + } +} + +setInterval(() => { + evaluateTrigger().catch((error) => { + console.error('[keeper] Trigger scheduler error:', error.message); + }); +}, config.check_interval_seconds * 1000); + +app.listen(config.port, () => { + const { name, version } = getVersionInfo(); + console.log(`[keeper] ${name} v${version} listening on port ${config.port}`); + console.log(`[keeper] Database: ${config.database_path}`); + console.log(`[keeper] Trigger script: ${config.trigger_script}`); + evaluateTrigger().catch((error) => { + console.error('[keeper] Initial trigger check failed:', error.message); + }); +}); diff --git a/src/trigger.js b/src/trigger.js new file mode 100644 index 0000000..5dbebac --- /dev/null +++ b/src/trigger.js @@ -0,0 +1,53 @@ +const fs = require('fs'); +const { spawn } = require('child_process'); + +function runTriggerScript(scriptPath, collectionPath) { + return new Promise((resolve) => { + if (!fs.existsSync(scriptPath)) { + resolve({ + success: false, + output: `Script not found: ${scriptPath}`, + }); + return; + } + + const child = spawn('sh', [scriptPath], { + env: { + ...process.env, + COLLECTION_PATH: collectionPath, + KEEPER_SCRIPT: scriptPath, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + resolve({ + success: false, + output: error.message, + }); + }); + + child.on('close', (code) => { + const output = [stdout.trim(), stderr.trim()].filter(Boolean).join('\n'); + resolve({ + success: code === 0, + output: output || `Script exited with code ${code}`, + }); + }); + }); +} + +module.exports = { + runTriggerScript, +}; diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..ebf5812 --- /dev/null +++ b/src/version.js @@ -0,0 +1,12 @@ +const packageJson = require('../package.json'); + +function getVersionInfo() { + return { + name: packageJson.name, + version: packageJson.version, + }; +} + +module.exports = { + getVersionInfo, +};