초기 버전 v1.0.0 구현
개인 NAS용 생존 확인 웹앱의 기본 기능, Docker 배포 설정, 설치 문서를 추가합니다.
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.cursor
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
.DS_Store
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
.DS_Store
|
||||
.env
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal 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
403
README.md
Normal 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
|
||||
```
|
||||
12
assets/backgrounds/bg01.svg
Normal file
12
assets/backgrounds/bg01.svg
Normal 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 |
12
assets/backgrounds/bg02.svg
Normal file
12
assets/backgrounds/bg02.svg
Normal 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 |
12
assets/backgrounds/bg03.svg
Normal file
12
assets/backgrounds/bg03.svg
Normal 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
13
config/default.json
Normal 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
30
docker-compose.yml
Normal 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
363
docs/DEPLOY.md
Normal 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
1255
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal 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
378
public/app.js
Normal 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
82
public/custom.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Collection Keeper 사용자 설정 파일
|
||||
// 이 파일만 수정하면 화면 문구, 클릭 이모지, 클릭 메시지를 바꿀 수 있습니다.
|
||||
|
||||
window.COLLECTION_KEEPER_CUSTOM = {
|
||||
// 오른쪽 아래에 표시할 글입니다. 줄을 추가하거나 지우면 화면에도 그대로 반영됩니다.
|
||||
keeperTextLines: [
|
||||
'가끔은 생각해.',
|
||||
'',
|
||||
'누군가 버튼을 누른다는 건',
|
||||
'이곳을 기억하고 있다는 뜻일까,',
|
||||
'',
|
||||
'아니면 그냥 지나가다 우연히 발견한 걸까,',
|
||||
'',
|
||||
'사실 어느 쪽이든 괜찮아,',
|
||||
'',
|
||||
'버튼이 눌리는 순간만큼은',
|
||||
'이곳도 조금 살아있는 것 같으니까.',
|
||||
],
|
||||
|
||||
// 남은 기간이 0이 되었을 때 화면 중앙에 표시할 작별 인사입니다.
|
||||
farewellTextLines: [
|
||||
'안녕.',
|
||||
'고마웠어.',
|
||||
],
|
||||
|
||||
// 클릭할 때 기록되고 화면에 표시될 이모지입니다.
|
||||
emojis: ['🐱', '🐰', '🐥', '🦊', '🍀', '🌸', '✨', '🎉', '💫', '🌈', '🍓', '🌙'],
|
||||
|
||||
// 클릭할 때 이모지 뒤에 함께 나올 문장입니다.
|
||||
clickMessages: [
|
||||
'오늘은 조금 조용하네.',
|
||||
'버튼 누르는 거 잊지 마.',
|
||||
'여기까지 찾아왔구나.',
|
||||
'컬렉션은 오늘도 무사해.',
|
||||
'아직 아무 일도 없었어.',
|
||||
'안녕. 오늘도 와줬네.',
|
||||
'버튼을 눌러주면 조금 안심이 돼.',
|
||||
'가끔은 아무도 오지 않는 날도 있어.',
|
||||
'괜찮아. 천천히 둘러보다 가.',
|
||||
'기억은 생각보다 오래 남아.',
|
||||
'나는 계속 여기 있었어.',
|
||||
'버튼 하나 정도는 눌러줄 수 있지?',
|
||||
'이곳은 아직 정상적으로 작동 중이야.',
|
||||
'이상 없음. 조금 심심한 것 빼고는.',
|
||||
'언젠가는 전부 잊어버릴지도 몰라.',
|
||||
'그래도 지금은 기억하고 있어.',
|
||||
'최근에 누군가 다녀간 흔적이 남아있어.',
|
||||
'그 흔적이 너였을까?',
|
||||
'조금 늦게 와도 괜찮아.',
|
||||
'오래 비워두지만 않으면 돼.',
|
||||
'오늘은 어떤 하루였어?',
|
||||
'별 의미 없는 질문이지만.',
|
||||
'가끔은 버튼을 누르는 이유도 잊어버려.',
|
||||
'그래도 누군가는 눌러주더라.',
|
||||
'시간은 계속 흐르고 있어.',
|
||||
'나는 그냥 지켜보고 있을 뿐이야.',
|
||||
'여기는 조금 느리게 흘러가.',
|
||||
'수집한 것들은 아직 그대로야.',
|
||||
'잊지 않고 찾아와줘서 고마워.',
|
||||
'오늘도 무사히 확인 완료.',
|
||||
'다음에 또 보자.',
|
||||
'가끔 내가 먼저 기억하는 건지 헷갈려.',
|
||||
'버튼이 눌리면 여기 시간도 조금 움직여.',
|
||||
'아무도 누르지 않으면 이곳은 잠이 들어.',
|
||||
'오늘은 아직 아무도 다녀가지 않았어.',
|
||||
'혹시 길을 잃고 들어온 거야?',
|
||||
'괜찮아. 대부분 처음엔 그렇게 들어와.',
|
||||
'여기까지 온 김에 버튼 하나만 부탁할게.',
|
||||
'나는 기다리는 일에는 익숙해.',
|
||||
'생각난 김에 눌러도 충분해.',
|
||||
'잊어버리지 않았다면 그걸로 됐어.',
|
||||
'오늘도 평화로워.',
|
||||
'아직은 모든 것이 제자리에 있어.',
|
||||
'이곳은 조용할수록 좋은 곳이야.',
|
||||
'버튼 하나로 충분해.',
|
||||
'네가 왔다는 것만으로도 의미가 있어.',
|
||||
'오늘도 컬렉션을 지키고 있었어.',
|
||||
'별일 없는 하루가 제일 좋은 법이야.',
|
||||
'여기는 늘 같은 자리에 있어.',
|
||||
'생각보다 오래 함께하고 있네.'
|
||||
],
|
||||
};
|
||||
47
public/index.html
Normal file
47
public/index.html
Normal 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
332
public/styles.css
Normal 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
20
scripts/cleanup.sh
Executable 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
8
scripts/encrypt.sh
Executable 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
8
scripts/warning.sh
Executable 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
104
src/config.js
Normal 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
283
src/db.js
Normal 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
108
src/server.js
Normal 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
53
src/trigger.js
Normal 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
12
src/version.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
function getVersionInfo() {
|
||||
return {
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getVersionInfo,
|
||||
};
|
||||
Reference in New Issue
Block a user