초기 버전 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