초기 버전 v1.0.0 구현

개인 NAS용 생존 확인 웹앱의 기본 기능, Docker 배포 설정, 설치 문서를 추가합니다.
This commit is contained in:
2026-06-10 11:48:06 +09:00
commit f7ee8cb923
24 changed files with 3593 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
data
.git
.gitignore
README.md
.cursor
*.sqlite
*.sqlite-journal
.DS_Store

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
data/
*.sqlite
*.sqlite-journal
.DS_Store
.env

25
Dockerfile Normal file
View File

@@ -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"]

403
README.md Normal file
View File

@@ -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
```

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
<defs>
<linearGradient id="g1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffe3f0"/>
<stop offset="50%" stop-color="#f4f0ff"/>
<stop offset="100%" stop-color="#e7fff4"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#g1)"/>
<circle cx="260" cy="180" r="120" fill="#ffd6e8" opacity="0.8"/>
<circle cx="1320" cy="720" r="180" fill="#d8ecff" opacity="0.75"/>
</svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
<defs>
<linearGradient id="g2" x1="100%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fff8e8"/>
<stop offset="50%" stop-color="#ffe8f4"/>
<stop offset="100%" stop-color="#edf7ff"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#g2)"/>
<circle cx="1180" cy="220" r="140" fill="#ffe0b8" opacity="0.7"/>
<circle cx="420" cy="680" r="160" fill="#ffc9dd" opacity="0.65"/>
</svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
<defs>
<linearGradient id="g3" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f7f0ff"/>
<stop offset="50%" stop-color="#fff0f6"/>
<stop offset="100%" stop-color="#f0fff8"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#g3)"/>
<circle cx="900" cy="160" r="110" fill="#e7d7ff" opacity="0.8"/>
<circle cx="300" cy="760" r="150" fill="#d7f7ef" opacity="0.75"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

13
config/default.json Normal file
View File

@@ -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": ""
}

30
docker-compose.yml Normal file
View File

@@ -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:

363
docs/DEPLOY.md Normal file
View File

@@ -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
```
이 방식이 업데이트와 버전 관리에 훨씬 유리합니다.

1255
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -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"
}
}

378
public/app.js Normal file
View File

@@ -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);

82
public/custom.js Normal file
View File

@@ -0,0 +1,82 @@
// Collection Keeper 사용자 설정 파일
// 이 파일만 수정하면 화면 문구, 클릭 이모지, 클릭 메시지를 바꿀 수 있습니다.
window.COLLECTION_KEEPER_CUSTOM = {
// 오른쪽 아래에 표시할 글입니다. 줄을 추가하거나 지우면 화면에도 그대로 반영됩니다.
keeperTextLines: [
'가끔은 생각해.',
'',
'누군가 버튼을 누른다는 건',
'이곳을 기억하고 있다는 뜻일까,',
'',
'아니면 그냥 지나가다 우연히 발견한 걸까,',
'',
'사실 어느 쪽이든 괜찮아,',
'',
'버튼이 눌리는 순간만큼은',
'이곳도 조금 살아있는 것 같으니까.',
],
// 남은 기간이 0이 되었을 때 화면 중앙에 표시할 작별 인사입니다.
farewellTextLines: [
'안녕.',
'고마웠어.',
],
// 클릭할 때 기록되고 화면에 표시될 이모지입니다.
emojis: ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙'],
// 클릭할 때 이모지 뒤에 함께 나올 문장입니다.
clickMessages: [
'오늘은 조금 조용하네.',
'버튼 누르는 거 잊지 마.',
'여기까지 찾아왔구나.',
'컬렉션은 오늘도 무사해.',
'아직 아무 일도 없었어.',
'안녕. 오늘도 와줬네.',
'버튼을 눌러주면 조금 안심이 돼.',
'가끔은 아무도 오지 않는 날도 있어.',
'괜찮아. 천천히 둘러보다 가.',
'기억은 생각보다 오래 남아.',
'나는 계속 여기 있었어.',
'버튼 하나 정도는 눌러줄 수 있지?',
'이곳은 아직 정상적으로 작동 중이야.',
'이상 없음. 조금 심심한 것 빼고는.',
'언젠가는 전부 잊어버릴지도 몰라.',
'그래도 지금은 기억하고 있어.',
'최근에 누군가 다녀간 흔적이 남아있어.',
'그 흔적이 너였을까?',
'조금 늦게 와도 괜찮아.',
'오래 비워두지만 않으면 돼.',
'오늘은 어떤 하루였어?',
'별 의미 없는 질문이지만.',
'가끔은 버튼을 누르는 이유도 잊어버려.',
'그래도 누군가는 눌러주더라.',
'시간은 계속 흐르고 있어.',
'나는 그냥 지켜보고 있을 뿐이야.',
'여기는 조금 느리게 흘러가.',
'수집한 것들은 아직 그대로야.',
'잊지 않고 찾아와줘서 고마워.',
'오늘도 무사히 확인 완료.',
'다음에 또 보자.',
'가끔 내가 먼저 기억하는 건지 헷갈려.',
'버튼이 눌리면 여기 시간도 조금 움직여.',
'아무도 누르지 않으면 이곳은 잠이 들어.',
'오늘은 아직 아무도 다녀가지 않았어.',
'혹시 길을 잃고 들어온 거야?',
'괜찮아. 대부분 처음엔 그렇게 들어와.',
'여기까지 온 김에 버튼 하나만 부탁할게.',
'나는 기다리는 일에는 익숙해.',
'생각난 김에 눌러도 충분해.',
'잊어버리지 않았다면 그걸로 됐어.',
'오늘도 평화로워.',
'아직은 모든 것이 제자리에 있어.',
'이곳은 조용할수록 좋은 곳이야.',
'버튼 하나로 충분해.',
'네가 왔다는 것만으로도 의미가 있어.',
'오늘도 컬렉션을 지키고 있었어.',
'별일 없는 하루가 제일 좋은 법이야.',
'여기는 늘 같은 자리에 있어.',
'생각보다 오래 함께하고 있네.'
],
};

47
public/index.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Collection Keeper</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="background-layer" class="background-layer" aria-hidden="true"></div>
<div id="effect-layer" class="effect-layer" aria-hidden="true"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<main class="app">
<section id="click-stage" class="click-stage" aria-label="생존 확인 클릭 영역"></section>
<section id="farewell-screen" class="farewell-screen" aria-live="polite"></section>
<aside class="keeper-panel" aria-live="polite">
<div id="keeper-text" class="keeper-text"></div>
<div class="text-stats">
<div>
<span>마지막 확인:</span>
<strong id="last-check">-</strong>
</div>
<div>
<span>총 확인 횟수:</span>
<strong id="total-checks">-</strong>
</div>
<div>
<span>남은 기간:</span>
<strong id="remaining-days">-</strong>
</div>
</div>
</aside>
<aside class="traces-panel" aria-live="polite">
<h2>Log</h2>
<ul id="recent-traces" class="recent-traces"></ul>
</aside>
</main>
<script src="/custom.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>

332
public/styles.css Normal file
View File

@@ -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;
}
}

20
scripts/cleanup.sh Executable file
View File

@@ -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"

8
scripts/encrypt.sh Executable file
View File

@@ -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

8
scripts/warning.sh Executable file
View File

@@ -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

104
src/config.js Normal file
View File

@@ -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,
};

283
src/db.js Normal file
View File

@@ -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,
};

108
src/server.js Normal file
View File

@@ -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);
});
});

53
src/trigger.js Normal file
View File

@@ -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,
};

12
src/version.js Normal file
View File

@@ -0,0 +1,12 @@
const packageJson = require('../package.json');
function getVersionInfo() {
return {
name: packageJson.name,
version: packageJson.version,
};
}
module.exports = {
getVersionInfo,
};