web-counter 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- web_counter-0.1.0/LICENSE +21 -0
- web_counter-0.1.0/PKG-INFO +181 -0
- web_counter-0.1.0/README.md +166 -0
- web_counter-0.1.0/pyproject.toml +27 -0
- web_counter-0.1.0/setup.cfg +4 -0
- web_counter-0.1.0/web_counter/__init__.py +1 -0
- web_counter-0.1.0/web_counter/auth.py +44 -0
- web_counter-0.1.0/web_counter/cli.py +249 -0
- web_counter-0.1.0/web_counter/config.py +48 -0
- web_counter-0.1.0/web_counter/dashboard.py +293 -0
- web_counter-0.1.0/web_counter/database.py +226 -0
- web_counter-0.1.0/web_counter/main.py +206 -0
- web_counter-0.1.0/web_counter/models.py +32 -0
- web_counter-0.1.0/web_counter/rate_limit.py +29 -0
- web_counter-0.1.0/web_counter/static/counter.js +150 -0
- web_counter-0.1.0/web_counter.egg-info/PKG-INFO +181 -0
- web_counter-0.1.0/web_counter.egg-info/SOURCES.txt +19 -0
- web_counter-0.1.0/web_counter.egg-info/dependency_links.txt +1 -0
- web_counter-0.1.0/web_counter.egg-info/entry_points.txt +2 -0
- web_counter-0.1.0/web_counter.egg-info/requires.txt +5 -0
- web_counter-0.1.0/web_counter.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mikigo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: web-counter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A self-hosted website visit counter with zero-config frontend integration
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: fastapi>=0.100.0
|
|
10
|
+
Requires-Dist: uvicorn[standard]
|
|
11
|
+
Requires-Dist: aiosqlite
|
|
12
|
+
Requires-Dist: bcrypt
|
|
13
|
+
Requires-Dist: python-multipart
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# web-counter
|
|
17
|
+
|
|
18
|
+
一个开源、可自托管的网站访问计数服务。一行命令部署,一行 `<script>` 接入,数据完全由你掌控。
|
|
19
|
+
|
|
20
|
+
## 统计指标
|
|
21
|
+
|
|
22
|
+
| 指标 | 含义 |
|
|
23
|
+
|------|------|
|
|
24
|
+
| 今日访问量 (PV Today) | 今天所有页面的总访问次数 |
|
|
25
|
+
| 今日访客数 (UV Today) | 今天的独立访客数(IP + salt 哈希去重) |
|
|
26
|
+
| 总访问量 (PV Site) | 建站以来所有页面的累计访问次数 |
|
|
27
|
+
| 总访客数 (UV Site) | 建站以来的累计独立访客数 |
|
|
28
|
+
| 页面阅读量 (PV Page) | 当前页面的累计访问次数 |
|
|
29
|
+
|
|
30
|
+
## 快速开始
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# 1. 安装
|
|
34
|
+
pip install web-counter
|
|
35
|
+
|
|
36
|
+
# 2. 设置 salt(必填,用于哈希访客 IP 保护隐私)
|
|
37
|
+
export COUNTER_SALT="$(openssl rand -hex 16)"
|
|
38
|
+
|
|
39
|
+
# 3. 启动
|
|
40
|
+
web-counter start
|
|
41
|
+
|
|
42
|
+
# 4. 创建管理员(用于访问统计看板)
|
|
43
|
+
web-counter createsuperuser
|
|
44
|
+
|
|
45
|
+
# 5. 将终端输出的代码粘贴到网页 </body> 前,完成接入
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 前端接入
|
|
49
|
+
|
|
50
|
+
在网页 `</body>` 前添加以下代码:
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<script async src="https://你的域名/counter.js"></script>
|
|
54
|
+
|
|
55
|
+
<!-- 显示计数(放在 display:none 容器中,JS 加载后自动显示) -->
|
|
56
|
+
<span style="display:none" class="counter-container" data-counter-style="card">
|
|
57
|
+
本站访问次数 <span data-pv-site></span> 次 ·
|
|
58
|
+
今日访问量 <span data-pv-today></span> 次 ·
|
|
59
|
+
今日访客 <span data-uv-today></span> 人 ·
|
|
60
|
+
总访客 <span data-uv-site></span> 人
|
|
61
|
+
</span>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 数据属性一览
|
|
65
|
+
|
|
66
|
+
| 属性 | 含义 |
|
|
67
|
+
|------|------|
|
|
68
|
+
| `data-pv-today` | 今日访问量 |
|
|
69
|
+
| `data-uv-today` | 今日访客数 |
|
|
70
|
+
| `data-pv-site` | 总访问量 |
|
|
71
|
+
| `data-uv-site` | 总访客数 |
|
|
72
|
+
| `data-pv-page` | 当前页面阅读量 |
|
|
73
|
+
| `data-counter-style` | 展示风格:`default` / `badge` / `card` / `bordered` |
|
|
74
|
+
| `data-counter-api` | 手动指定 API 基地址(JS 独立部署时使用) |
|
|
75
|
+
|
|
76
|
+
### SPA 支持
|
|
77
|
+
|
|
78
|
+
对于 Vue/React 等单页应用,路由切换不会触发计数。可在路由切换后手动上报:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
fetch('https://你的域名/api/visit', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ path: window.location.pathname })
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## CLI 命令
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
web-counter start # 后台启动服务
|
|
92
|
+
web-counter restart # 重启服务
|
|
93
|
+
web-counter stop # 停止服务
|
|
94
|
+
web-counter createsuperuser # 创建管理员账号
|
|
95
|
+
web-counter export-js # 导出 counter.js 到 stdout
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 配置
|
|
99
|
+
|
|
100
|
+
优先级:**CLI 参数 > 环境变量 > .env 文件**
|
|
101
|
+
|
|
102
|
+
| 参数 | 环境变量 | 默认值 | 说明 |
|
|
103
|
+
|------|------|--------|------|
|
|
104
|
+
| `--salt` | `COUNTER_SALT` | 无(必填) | IP 哈希盐值 |
|
|
105
|
+
| `--host` | `COUNTER_HOST` | `0.0.0.0` | 监听地址 |
|
|
106
|
+
| `--port` | `COUNTER_PORT` | `8000` | 监听端口 |
|
|
107
|
+
| `--db-path` | `COUNTER_DB_PATH` | `./data/counter.db` | 数据库路径 |
|
|
108
|
+
| `--pid-file` | `COUNTER_PID_FILE` | `./data/counter.pid` | PID 文件路径 |
|
|
109
|
+
| `--allowed-origins` | `COUNTER_ALLOWED_ORIGINS` | `*` | CORS 允许域名 |
|
|
110
|
+
| `--rate-limit` | `COUNTER_RATE_LIMIT` | `60` | 每 IP 每分钟限流 |
|
|
111
|
+
|
|
112
|
+
## API 接口
|
|
113
|
+
|
|
114
|
+
| 方法 | 路径 | 说明 |
|
|
115
|
+
|------|------|------|
|
|
116
|
+
| POST | `/api/visit` | 记录访问 |
|
|
117
|
+
| GET | `/api/count?paths=/page1,/page2` | 批量查询计数 |
|
|
118
|
+
| GET | `/counter.js` | 获取前端 JS 脚本 |
|
|
119
|
+
| GET | `/api/health` | 健康检查 |
|
|
120
|
+
| GET | `/dashboard` | 统计看板(需登录) |
|
|
121
|
+
| POST | `/api/admin/login` | 管理员登录(JSON) |
|
|
122
|
+
| POST | `/api/admin/logout` | 退出登录 |
|
|
123
|
+
| POST | `/api/admin/reset` | 重置数据(需登录) |
|
|
124
|
+
| GET/POST | `/api/admin/offset` | 查看/设置起始值(需登录) |
|
|
125
|
+
|
|
126
|
+
## 生产环境部署
|
|
127
|
+
|
|
128
|
+
### 使用反代 (Caddy)
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
your-domain.com {
|
|
132
|
+
reverse_proxy localhost:8000
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 使用 systemd
|
|
137
|
+
|
|
138
|
+
```ini
|
|
139
|
+
[Unit]
|
|
140
|
+
Description=web-counter
|
|
141
|
+
After=network.target
|
|
142
|
+
|
|
143
|
+
[Service]
|
|
144
|
+
Type=forking
|
|
145
|
+
PIDFile=/opt/web-counter/data/counter.pid
|
|
146
|
+
Environment="COUNTER_SALT=your-random-salt"
|
|
147
|
+
ExecStart=/usr/bin/web-counter start
|
|
148
|
+
ExecStop=/usr/bin/web-counter stop
|
|
149
|
+
ExecReload=/usr/bin/web-counter restart
|
|
150
|
+
Restart=on-failure
|
|
151
|
+
|
|
152
|
+
[Install]
|
|
153
|
+
WantedBy=multi-user.target
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 使用 Docker
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
docker compose up -d
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## 数据管理
|
|
163
|
+
|
|
164
|
+
后台登录 `/dashboard` 后可进行:
|
|
165
|
+
|
|
166
|
+
- **数据重置**:支持重置今日/全部/指定页面的访问数据
|
|
167
|
+
- **起始值设置**:为累计指标设置偏移量,适用于从其他统计工具迁移
|
|
168
|
+
|
|
169
|
+
## 隐私设计
|
|
170
|
+
|
|
171
|
+
- 不存储原始 IP 地址
|
|
172
|
+
- 不设置 Cookie(后台 session cookie 除外)
|
|
173
|
+
- 不做浏览器指纹
|
|
174
|
+
- 访客唯一性通过 `SHA256(IP + salt)` 标识
|
|
175
|
+
- salt 由部署者自行设置,无法被外部反向破解
|
|
176
|
+
|
|
177
|
+
> **注意**:salt 设定后不要更换,否则历史访客标识失联,UV 统计将清零重来。
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# web-counter
|
|
2
|
+
|
|
3
|
+
一个开源、可自托管的网站访问计数服务。一行命令部署,一行 `<script>` 接入,数据完全由你掌控。
|
|
4
|
+
|
|
5
|
+
## 统计指标
|
|
6
|
+
|
|
7
|
+
| 指标 | 含义 |
|
|
8
|
+
|------|------|
|
|
9
|
+
| 今日访问量 (PV Today) | 今天所有页面的总访问次数 |
|
|
10
|
+
| 今日访客数 (UV Today) | 今天的独立访客数(IP + salt 哈希去重) |
|
|
11
|
+
| 总访问量 (PV Site) | 建站以来所有页面的累计访问次数 |
|
|
12
|
+
| 总访客数 (UV Site) | 建站以来的累计独立访客数 |
|
|
13
|
+
| 页面阅读量 (PV Page) | 当前页面的累计访问次数 |
|
|
14
|
+
|
|
15
|
+
## 快速开始
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 1. 安装
|
|
19
|
+
pip install web-counter
|
|
20
|
+
|
|
21
|
+
# 2. 设置 salt(必填,用于哈希访客 IP 保护隐私)
|
|
22
|
+
export COUNTER_SALT="$(openssl rand -hex 16)"
|
|
23
|
+
|
|
24
|
+
# 3. 启动
|
|
25
|
+
web-counter start
|
|
26
|
+
|
|
27
|
+
# 4. 创建管理员(用于访问统计看板)
|
|
28
|
+
web-counter createsuperuser
|
|
29
|
+
|
|
30
|
+
# 5. 将终端输出的代码粘贴到网页 </body> 前,完成接入
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 前端接入
|
|
34
|
+
|
|
35
|
+
在网页 `</body>` 前添加以下代码:
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
<script async src="https://你的域名/counter.js"></script>
|
|
39
|
+
|
|
40
|
+
<!-- 显示计数(放在 display:none 容器中,JS 加载后自动显示) -->
|
|
41
|
+
<span style="display:none" class="counter-container" data-counter-style="card">
|
|
42
|
+
本站访问次数 <span data-pv-site></span> 次 ·
|
|
43
|
+
今日访问量 <span data-pv-today></span> 次 ·
|
|
44
|
+
今日访客 <span data-uv-today></span> 人 ·
|
|
45
|
+
总访客 <span data-uv-site></span> 人
|
|
46
|
+
</span>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 数据属性一览
|
|
50
|
+
|
|
51
|
+
| 属性 | 含义 |
|
|
52
|
+
|------|------|
|
|
53
|
+
| `data-pv-today` | 今日访问量 |
|
|
54
|
+
| `data-uv-today` | 今日访客数 |
|
|
55
|
+
| `data-pv-site` | 总访问量 |
|
|
56
|
+
| `data-uv-site` | 总访客数 |
|
|
57
|
+
| `data-pv-page` | 当前页面阅读量 |
|
|
58
|
+
| `data-counter-style` | 展示风格:`default` / `badge` / `card` / `bordered` |
|
|
59
|
+
| `data-counter-api` | 手动指定 API 基地址(JS 独立部署时使用) |
|
|
60
|
+
|
|
61
|
+
### SPA 支持
|
|
62
|
+
|
|
63
|
+
对于 Vue/React 等单页应用,路由切换不会触发计数。可在路由切换后手动上报:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
fetch('https://你的域名/api/visit', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ path: window.location.pathname })
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## CLI 命令
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
web-counter start # 后台启动服务
|
|
77
|
+
web-counter restart # 重启服务
|
|
78
|
+
web-counter stop # 停止服务
|
|
79
|
+
web-counter createsuperuser # 创建管理员账号
|
|
80
|
+
web-counter export-js # 导出 counter.js 到 stdout
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 配置
|
|
84
|
+
|
|
85
|
+
优先级:**CLI 参数 > 环境变量 > .env 文件**
|
|
86
|
+
|
|
87
|
+
| 参数 | 环境变量 | 默认值 | 说明 |
|
|
88
|
+
|------|------|--------|------|
|
|
89
|
+
| `--salt` | `COUNTER_SALT` | 无(必填) | IP 哈希盐值 |
|
|
90
|
+
| `--host` | `COUNTER_HOST` | `0.0.0.0` | 监听地址 |
|
|
91
|
+
| `--port` | `COUNTER_PORT` | `8000` | 监听端口 |
|
|
92
|
+
| `--db-path` | `COUNTER_DB_PATH` | `./data/counter.db` | 数据库路径 |
|
|
93
|
+
| `--pid-file` | `COUNTER_PID_FILE` | `./data/counter.pid` | PID 文件路径 |
|
|
94
|
+
| `--allowed-origins` | `COUNTER_ALLOWED_ORIGINS` | `*` | CORS 允许域名 |
|
|
95
|
+
| `--rate-limit` | `COUNTER_RATE_LIMIT` | `60` | 每 IP 每分钟限流 |
|
|
96
|
+
|
|
97
|
+
## API 接口
|
|
98
|
+
|
|
99
|
+
| 方法 | 路径 | 说明 |
|
|
100
|
+
|------|------|------|
|
|
101
|
+
| POST | `/api/visit` | 记录访问 |
|
|
102
|
+
| GET | `/api/count?paths=/page1,/page2` | 批量查询计数 |
|
|
103
|
+
| GET | `/counter.js` | 获取前端 JS 脚本 |
|
|
104
|
+
| GET | `/api/health` | 健康检查 |
|
|
105
|
+
| GET | `/dashboard` | 统计看板(需登录) |
|
|
106
|
+
| POST | `/api/admin/login` | 管理员登录(JSON) |
|
|
107
|
+
| POST | `/api/admin/logout` | 退出登录 |
|
|
108
|
+
| POST | `/api/admin/reset` | 重置数据(需登录) |
|
|
109
|
+
| GET/POST | `/api/admin/offset` | 查看/设置起始值(需登录) |
|
|
110
|
+
|
|
111
|
+
## 生产环境部署
|
|
112
|
+
|
|
113
|
+
### 使用反代 (Caddy)
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
your-domain.com {
|
|
117
|
+
reverse_proxy localhost:8000
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 使用 systemd
|
|
122
|
+
|
|
123
|
+
```ini
|
|
124
|
+
[Unit]
|
|
125
|
+
Description=web-counter
|
|
126
|
+
After=network.target
|
|
127
|
+
|
|
128
|
+
[Service]
|
|
129
|
+
Type=forking
|
|
130
|
+
PIDFile=/opt/web-counter/data/counter.pid
|
|
131
|
+
Environment="COUNTER_SALT=your-random-salt"
|
|
132
|
+
ExecStart=/usr/bin/web-counter start
|
|
133
|
+
ExecStop=/usr/bin/web-counter stop
|
|
134
|
+
ExecReload=/usr/bin/web-counter restart
|
|
135
|
+
Restart=on-failure
|
|
136
|
+
|
|
137
|
+
[Install]
|
|
138
|
+
WantedBy=multi-user.target
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 使用 Docker
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
docker compose up -d
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 数据管理
|
|
148
|
+
|
|
149
|
+
后台登录 `/dashboard` 后可进行:
|
|
150
|
+
|
|
151
|
+
- **数据重置**:支持重置今日/全部/指定页面的访问数据
|
|
152
|
+
- **起始值设置**:为累计指标设置偏移量,适用于从其他统计工具迁移
|
|
153
|
+
|
|
154
|
+
## 隐私设计
|
|
155
|
+
|
|
156
|
+
- 不存储原始 IP 地址
|
|
157
|
+
- 不设置 Cookie(后台 session cookie 除外)
|
|
158
|
+
- 不做浏览器指纹
|
|
159
|
+
- 访客唯一性通过 `SHA256(IP + salt)` 标识
|
|
160
|
+
- salt 由部署者自行设置,无法被外部反向破解
|
|
161
|
+
|
|
162
|
+
> **注意**:salt 设定后不要更换,否则历史访客标识失联,UV 统计将清零重来。
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "web-counter"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A self-hosted website visit counter with zero-config frontend integration"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"fastapi>=0.100.0",
|
|
14
|
+
"uvicorn[standard]",
|
|
15
|
+
"aiosqlite",
|
|
16
|
+
"bcrypt",
|
|
17
|
+
"python-multipart",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
web-counter = "web_counter.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["web_counter*"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
web_counter = ["static/*.js", "templates/*.html"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""web-counter: A self-hosted website visit counter."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Authentication: bcrypt password hashing and session management."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import bcrypt
|
|
7
|
+
|
|
8
|
+
# In-memory session store: token -> {"username": str, "created_at": float}
|
|
9
|
+
_sessions: dict[str, dict] = {}
|
|
10
|
+
|
|
11
|
+
SESSION_MAX_AGE = 86400 # 24 hours
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hash_password(password: str) -> str:
|
|
15
|
+
"""Hash a password with bcrypt."""
|
|
16
|
+
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify_password(password: str, password_hash: str) -> bool:
|
|
20
|
+
"""Verify a password against its bcrypt hash."""
|
|
21
|
+
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_session(username: str) -> str:
|
|
25
|
+
"""Create a new session token for the given username."""
|
|
26
|
+
token = secrets.token_hex(32)
|
|
27
|
+
_sessions[token] = {"username": username, "created_at": time.time()}
|
|
28
|
+
return token
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_session(token: str) -> str | None:
|
|
32
|
+
"""Validate a session token. Returns username if valid, None otherwise."""
|
|
33
|
+
if token not in _sessions:
|
|
34
|
+
return None
|
|
35
|
+
session = _sessions[token]
|
|
36
|
+
if time.time() - session["created_at"] > SESSION_MAX_AGE:
|
|
37
|
+
del _sessions[token]
|
|
38
|
+
return None
|
|
39
|
+
return session["username"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def destroy_session(token: str):
|
|
43
|
+
"""Destroy a session token."""
|
|
44
|
+
_sessions.pop(token, None)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""CLI: start, restart, stop, createsuperuser, export-js."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _read_pid(pid_file: str) -> int | None:
|
|
14
|
+
"""Read PID from file."""
|
|
15
|
+
try:
|
|
16
|
+
with open(pid_file, "r") as f:
|
|
17
|
+
return int(f.read().strip())
|
|
18
|
+
except (FileNotFoundError, ValueError):
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_running(pid: int | None) -> bool:
|
|
23
|
+
"""Check if a process with given PID is running."""
|
|
24
|
+
if pid is None:
|
|
25
|
+
return False
|
|
26
|
+
try:
|
|
27
|
+
os.kill(pid, 0)
|
|
28
|
+
return True
|
|
29
|
+
except OSError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def start_command(args):
|
|
34
|
+
"""Start the web-counter server in the background."""
|
|
35
|
+
from .config import Config
|
|
36
|
+
|
|
37
|
+
config = Config(
|
|
38
|
+
salt=args.salt,
|
|
39
|
+
host=args.host,
|
|
40
|
+
port=args.port,
|
|
41
|
+
db_path=args.db_path,
|
|
42
|
+
pid_file=args.pid_file,
|
|
43
|
+
allowed_origins=args.allowed_origins,
|
|
44
|
+
rate_limit=args.rate_limit,
|
|
45
|
+
)
|
|
46
|
+
config.validate()
|
|
47
|
+
|
|
48
|
+
# Check if already running
|
|
49
|
+
pid = _read_pid(config.pid_file)
|
|
50
|
+
if _is_running(pid):
|
|
51
|
+
print(f"✗ web-counter 已在运行中 (PID: {pid})")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
# Ensure data directory exists
|
|
55
|
+
Path(config.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
Path(config.pid_file).parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
# Build command
|
|
59
|
+
cmd = [
|
|
60
|
+
sys.executable, "-m", "uvicorn", "web_counter.main:app",
|
|
61
|
+
"--host", config.host,
|
|
62
|
+
"--port", str(config.port),
|
|
63
|
+
"--log-level", "info",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# Set environment variables for the subprocess
|
|
67
|
+
env = os.environ.copy()
|
|
68
|
+
env["COUNTER_SALT"] = config.salt
|
|
69
|
+
env["COUNTER_HOST"] = config.host
|
|
70
|
+
env["COUNTER_PORT"] = str(config.port)
|
|
71
|
+
env["COUNTER_DB_PATH"] = config.db_path
|
|
72
|
+
env["COUNTER_PID_FILE"] = config.pid_file
|
|
73
|
+
env["COUNTER_ALLOWED_ORIGINS"] = config.allowed_origins
|
|
74
|
+
env["COUNTER_RATE_LIMIT"] = str(config.rate_limit)
|
|
75
|
+
|
|
76
|
+
# Start process (background)
|
|
77
|
+
proc = subprocess.Popen(
|
|
78
|
+
cmd,
|
|
79
|
+
env=env,
|
|
80
|
+
stdout=subprocess.DEVNULL,
|
|
81
|
+
stderr=subprocess.DEVNULL,
|
|
82
|
+
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Write PID file
|
|
86
|
+
with open(config.pid_file, "w") as f:
|
|
87
|
+
f.write(str(proc.pid))
|
|
88
|
+
|
|
89
|
+
origin = f"http://{config.host}:{config.port}"
|
|
90
|
+
print(f"✓ web-counter 已启动,监听 {origin}")
|
|
91
|
+
print()
|
|
92
|
+
print("---------- 复制以下代码到网页 </body> 前 ----------")
|
|
93
|
+
print()
|
|
94
|
+
print(f'<script async src="{origin}/counter.js"></script>')
|
|
95
|
+
print()
|
|
96
|
+
print('<span style="display:none" class="counter-container" data-counter-style="card">')
|
|
97
|
+
print(" 本站访问次数 <span data-pv-site></span> 次 ·")
|
|
98
|
+
print(" 今日访问量 <span data-pv-today></span> 次 ·")
|
|
99
|
+
print(" 今日访客 <span data-uv-today></span> 人 ·")
|
|
100
|
+
print(" 总访问量 <span data-pv-site></span> 次 ·")
|
|
101
|
+
print(" 总访客 <span data-uv-site></span> 人")
|
|
102
|
+
print("</span>")
|
|
103
|
+
print()
|
|
104
|
+
print("------------------------------------------------------")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def stop_command(args):
|
|
108
|
+
"""Stop the running web-counter server."""
|
|
109
|
+
from .config import Config
|
|
110
|
+
|
|
111
|
+
config = Config(pid_file=args.pid_file)
|
|
112
|
+
|
|
113
|
+
pid = _read_pid(config.pid_file)
|
|
114
|
+
if not _is_running(pid):
|
|
115
|
+
print("✗ web-counter 未在运行")
|
|
116
|
+
if Path(config.pid_file).exists():
|
|
117
|
+
Path(config.pid_file).unlink()
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
# Send SIGTERM for graceful shutdown
|
|
121
|
+
os.kill(pid, signal.SIGTERM)
|
|
122
|
+
|
|
123
|
+
# Wait for process to exit (max 10 seconds)
|
|
124
|
+
for _ in range(100):
|
|
125
|
+
if not _is_running(pid):
|
|
126
|
+
break
|
|
127
|
+
time.sleep(0.1)
|
|
128
|
+
|
|
129
|
+
if _is_running(pid):
|
|
130
|
+
print(f"✗ 无法停止进程 (PID: {pid}),尝试强制终止...")
|
|
131
|
+
try:
|
|
132
|
+
os.kill(pid, signal.SIGKILL)
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
135
|
+
time.sleep(0.5)
|
|
136
|
+
|
|
137
|
+
# Clean up PID file
|
|
138
|
+
if Path(config.pid_file).exists():
|
|
139
|
+
Path(config.pid_file).unlink()
|
|
140
|
+
|
|
141
|
+
print("✓ web-counter 已停止")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def restart_command(args):
|
|
145
|
+
"""Restart the web-counter server."""
|
|
146
|
+
print("正在重启 web-counter...")
|
|
147
|
+
stop_command(args)
|
|
148
|
+
start_command(args)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def createsuperuser_command(args):
|
|
152
|
+
"""Create an admin user interactively."""
|
|
153
|
+
from .config import Config
|
|
154
|
+
from .database import init_db, create_admin
|
|
155
|
+
from .auth import hash_password
|
|
156
|
+
|
|
157
|
+
config = Config(
|
|
158
|
+
salt="dummy",
|
|
159
|
+
db_path=args.db_path,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
username = input("Username: ").strip()
|
|
163
|
+
if not username:
|
|
164
|
+
print("✗ 用户名不能为空")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
password = input("Password: ").strip()
|
|
168
|
+
if not password:
|
|
169
|
+
print("✗ 密码不能为空")
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
|
|
172
|
+
confirm = input("Confirm password: ").strip()
|
|
173
|
+
if password != confirm:
|
|
174
|
+
print("✗ 两次输入的密码不一致")
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
|
|
177
|
+
async def _create():
|
|
178
|
+
await init_db(config.db_path)
|
|
179
|
+
password_hash = hash_password(password)
|
|
180
|
+
await create_admin(config.db_path, username, password_hash)
|
|
181
|
+
|
|
182
|
+
asyncio.run(_create())
|
|
183
|
+
print(f"✓ 管理员 {username} 创建成功")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def export_js_command(args):
|
|
187
|
+
"""Export counter.js to stdout."""
|
|
188
|
+
js_path = Path(__file__).parent / "static" / "counter.js"
|
|
189
|
+
print(js_path.read_text(encoding="utf-8"))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main():
|
|
193
|
+
"""CLI entry point."""
|
|
194
|
+
parser = argparse.ArgumentParser(
|
|
195
|
+
prog="web-counter",
|
|
196
|
+
description="A self-hosted website visit counter",
|
|
197
|
+
)
|
|
198
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
199
|
+
|
|
200
|
+
# start
|
|
201
|
+
p = subparsers.add_parser("start", help="Start the server in background")
|
|
202
|
+
p.add_argument("--salt", default=None, help="IP hash salt (required)")
|
|
203
|
+
p.add_argument("--host", default=None, help="Listen address (default: 0.0.0.0)")
|
|
204
|
+
p.add_argument("--port", type=int, default=None, help="Listen port (default: 8000)")
|
|
205
|
+
p.add_argument("--db-path", default=None, help="SQLite database path")
|
|
206
|
+
p.add_argument("--pid-file", default=None, help="PID file path")
|
|
207
|
+
p.add_argument("--allowed-origins", default=None, help="CORS allowed origins")
|
|
208
|
+
p.add_argument("--rate-limit", type=int, default=None, help="Rate limit per IP per minute")
|
|
209
|
+
|
|
210
|
+
# restart
|
|
211
|
+
p = subparsers.add_parser("restart", help="Restart the server")
|
|
212
|
+
p.add_argument("--salt", default=None, help="IP hash salt")
|
|
213
|
+
p.add_argument("--host", default=None, help="Listen address")
|
|
214
|
+
p.add_argument("--port", type=int, default=None, help="Listen port")
|
|
215
|
+
p.add_argument("--db-path", default=None, help="SQLite database path")
|
|
216
|
+
p.add_argument("--pid-file", default=None, help="PID file path")
|
|
217
|
+
p.add_argument("--allowed-origins", default=None, help="CORS allowed origins")
|
|
218
|
+
p.add_argument("--rate-limit", type=int, default=None, help="Rate limit per IP per minute")
|
|
219
|
+
|
|
220
|
+
# stop
|
|
221
|
+
p = subparsers.add_parser("stop", help="Stop the server")
|
|
222
|
+
p.add_argument("--pid-file", default=None, help="PID file path")
|
|
223
|
+
|
|
224
|
+
# createsuperuser
|
|
225
|
+
p = subparsers.add_parser("createsuperuser", help="Create an admin user")
|
|
226
|
+
p.add_argument("--db-path", default=None, help="SQLite database path")
|
|
227
|
+
|
|
228
|
+
# export-js
|
|
229
|
+
subparsers.add_parser("export-js", help="Export counter.js to stdout")
|
|
230
|
+
|
|
231
|
+
args = parser.parse_args()
|
|
232
|
+
|
|
233
|
+
if args.command == "start":
|
|
234
|
+
start_command(args)
|
|
235
|
+
elif args.command == "restart":
|
|
236
|
+
restart_command(args)
|
|
237
|
+
elif args.command == "stop":
|
|
238
|
+
stop_command(args)
|
|
239
|
+
elif args.command == "createsuperuser":
|
|
240
|
+
createsuperuser_command(args)
|
|
241
|
+
elif args.command == "export-js":
|
|
242
|
+
export_js_command(args)
|
|
243
|
+
else:
|
|
244
|
+
parser.print_help()
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|