@5ime/likeme 1.0.2 → 1.0.4

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.
package/.env.example ADDED
@@ -0,0 +1,7 @@
1
+ POSTGRES_URL=postgres://...
2
+ PORT=3000
3
+ ALLOW_ORIGIN=*
4
+ TRUST_PROXY=1
5
+ INFO_CACHE_SECONDS=5
6
+ LIKE_RATE_LIMIT_WINDOW_MS=60000
7
+ LIKE_RATE_LIMIT_MAX=20
package/.gitattributes ADDED
@@ -0,0 +1,20 @@
1
+ * text=auto
2
+
3
+ # Normalize line endings to LF for source/docs to avoid noisy diffs across OSes
4
+ *.js text eol=lf
5
+ *.cjs text eol=lf
6
+ *.mjs text eol=lf
7
+ *.ts text eol=lf
8
+ *.tsx text eol=lf
9
+ *.json text eol=lf
10
+ *.md text eol=lf
11
+ *.html text eol=lf
12
+ *.css text eol=lf
13
+ *.svg text eol=lf
14
+ *.yml text eol=lf
15
+ *.yaml text eol=lf
16
+ *.toml text eol=lf
17
+
18
+ # Keep Windows scripts with CRLF if you add any in the future
19
+ *.bat text eol=crlf
20
+ *.cmd text eol=crlf
package/README.md CHANGED
@@ -1,81 +1,199 @@
1
- # likeMe
2
-
3
- > 🍰 快速为你的网站添加 "你喜欢我吗 / Do you like me" 小组件
4
-
5
- ---
6
-
7
- ## LeanCloud 设置
8
-
9
- 1. [登录](https://console.leancloud.app/login) 或 [注册](https://console.leancloud.app/register) `LeanCloud 国际版` 并进入 [控制台](https://console.leancloud.app/apps)。
10
- 2. 点击左上角 [创建应用](https://console.leancloud.app/apps) 并起一个你喜欢的名字(请选择免费的 **开发版**)。
11
- 3. 进入应用,选择左下角的 **设置 > 应用凭证**,你可以看到你的 **APP ID**、**APP Key** 和 **REST API 服务器地址**,请记录它们以备后续使用。
12
- 4. 进入应用,选择左上角的 **数据存储 > 结构化数据 > 创建 Class**:
13
- - **Class 名称** 填写为 `likeCount`。
14
- - **Class 访问权限**:除 `update` 和 `find` 外均选择 **指定用户**。
15
- - **默认 ACL 权限**:选定为 **限制读取**,然后点击 **创建**。
16
- 5. 选择新建的 `likeCount` Class,点击 **添加行**,并将 **ACL 权限** 设置为 **所有用户**,点击 **添加**,记录生成行的 `objectId`。
17
- 6. 再次 **创建 Class**,**Class 名称** 填写为 `likeUser`:
18
- - **Class 访问权限**:除 `create` 和 `find` 外均选择 **指定用户**。
19
- - **默认 ACL 权限**:选定为 **限制读取**,然后点击 **创建**。
20
-
21
- 此时你应该拥有以下信息:
22
- - `APP ID`
23
- - `APP Key`
24
- - `REST API 服务器地址`
25
- - `objectId` 🎉
26
-
27
- ---
28
-
29
- ## Vercel 部署
30
-
31
- [![Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/5ime/likeme)
32
-
33
- 1. 点击上方按钮跳转至 **Vercel** 进行部署。
34
- 2. 输入一个你喜欢的 Vercel 项目名称并点击 **Create**。
35
- 3. Vercel 会基于 `likeMe` 模板新建并初始化仓库,仓库名为你输入的项目名。一两分钟后,满屏的烟花会庆祝部署成功。
36
- 4. 点击 **Go to Dashboard** 跳转到控制台。
37
- 5. 进入顶部的 **Settings > Environment Variables** 配置以下环境变量:
38
- - `appId`:LeanCloud 的 `APP ID`。
39
- - `appKey`:LeanCloud 的 `APP Key`。
40
- - `serverURL`:LeanCloud 的 `REST API 服务器地址`。
41
- - `objectId`:`likeCount` 表中记录的 `objectId`。
42
- 6. 环境变量配置完成后,进入顶部的 **Deployments**,找到最新一次部署并点击右侧的 **Redeploy** 按钮以使环境变量生效。
43
- 7. 部署完成后,点击 **Visit** 跳转到部署好的地址,此地址即为你的服务端地址。
44
-
45
- ---
46
-
47
- ## 绑定域名 (可选)
48
-
49
- 1. 进入 **Settings > Domains** 配置域名。
50
- 2. 输入需要绑定的域名并点击 **Add**。
51
- 3. 在域名服务器商处添加新的 `CNAME` 记录:
52
- - **Type**: `CNAME`
53
- - **Name**: `example`
54
- - **Value**: `cname.vercel-dns.com`
55
- 4. 等待生效后,即可通过自己的域名访问 🎉。
56
-
57
- ---
58
-
59
- ## HTML 引入
60
-
61
- 在网页中进行如下设置:
62
-
63
- 1. 使用 `CDN` 引入 `likeMe`:
64
- ```html
65
- <link rel="stylesheet" href="https://unpkg.com/@5ime/likeme@latest/static/css/likeme.css" />
66
- <script src="https://unpkg.com/@5ime/likeme@latest/static/js/likeme.js"></script>
67
- ```
68
-
69
- 2. 创建 `<script>` 标签并初始化 `likeMe`:
70
- ```html
71
- <div id="likeme"></div>
72
- <script>
73
- likeMe({
74
- el: '#likeme',
75
- serverURL: 'https://your-domain.vercel.app',
76
- color: '#ff9797', // 可选:设置背景颜色
77
- });
78
- </script>
79
- ```
80
-
81
- 3. 此时 `likeMe` 就会在你的网站上成功运行 🎉。
1
+ # likeMe - 一个可爱的互动点赞组件
2
+
3
+ 快速给你的网站加一个「你喜欢我吗 / Do you like me」小组件。服务端可部署在 Vercel,点赞数据存 Vercel Postgres(Neon)。
4
+
5
+ ## 功能特性
6
+
7
+ - 🎨 简洁可爱的 UI 设计
8
+ - 🚀 一键部署到 Vercel
9
+ - 💾 数据存储在 Vercel Postgres(Neon)
10
+ - 🔒 防刷机制:同一 IP 每天只能点赞一次
11
+ - ⚡ 自动缓存优化,减少数据库查询
12
+ - 📦 支持 CDN 和本地文件两种使用方式
13
+ - 🎯 支持自定义主题颜色
14
+
15
+ ## 一键部署
16
+
17
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/5ime/likeMe)
18
+
19
+ 1. 点击上方按钮跳转至 **Vercel** 进行部署
20
+ 2. 输入一个你喜欢的 Vercel 项目名称并点击 **Create**
21
+ 3. 等待部署完成
22
+ 4. 点击 **Go to Dashboard** 跳转到控制台
23
+
24
+ ## 配置数据库
25
+
26
+ 本项目使用 Vercel Postgres(底层是 Neon)。你只需要在 Vercel 给当前项目绑定数据库,并确保环境变量里存在 `POSTGRES_URL`。
27
+
28
+ ### 1. 在 Vercel 创建并绑定数据库
29
+
30
+ 1. 打开你的 Vercel 项目
31
+ 2. 进入 **Storage**
32
+ 3. 点击 **Create Database**,选择 **Neon**
33
+ 4. 选择要绑定到的项目(通常就是当前项目)并创建
34
+
35
+ ### 2. 确认环境变量已注入
36
+
37
+ 创建完成后,Vercel 通常会自动把数据库连接信息注入到项目环境变量中。
38
+
39
+ - 进入 **Settings > Environment Variables**
40
+ - 搜索并确认存在:`POSTGRES_URL`
41
+
42
+ 如果没有自动注入,请手动添加:
43
+
44
+ 1. 进入 **Storage > Postgres 数据库详情页**
45
+ 2. 找到连接字符串(Connection String),复制 `POSTGRES_URL` 对应的值
46
+ 3. 回到 **Settings > Environment Variables**,新增变量:
47
+ - **Name**:`POSTGRES_URL`
48
+ - **Value**:你复制的连接字符串
49
+ - **Environments**:勾选 `Production / Preview / Development`
50
+ 4. 保存后到 **Deployments** 点击 **Redeploy**(或触发一次新的部署),让最新环境变量生效
51
+
52
+ ### 3. 如何验证数据库已可用
53
+
54
+ 最简单的方式:访问一次接口触发建表,然后再看是否能正常返回。
55
+
56
+ - `GET /healthz`:服务是否存活(返回 `ok`)
57
+ - `GET /info`:会读取数据库并返回当前点赞数
58
+
59
+ 如果数据库连不上,你通常会在 Vercel 的 **Logs** 里看到与连接/鉴权相关的报错(例如环境变量缺失、连接串无效等)。
60
+
61
+ > 本项目会在首次请求时自动建表(`like_count` / `like_user`),无需手动建表。
62
+
63
+ ## 在你的网站中使用
64
+
65
+ ### 方法一:使用 CDN
66
+
67
+ 在你的网页中添加以下代码:
68
+
69
+ ```html
70
+ <link rel="stylesheet" href="https://unpkg.com/@5ime/likeme@latest/static/css/likeme.css" />
71
+ <script src="https://unpkg.com/@5ime/likeme@latest/static/js/likeme.js"></script>
72
+
73
+ <div id="likeme"></div>
74
+
75
+ <script>
76
+ likeMe({
77
+ el: '#likeme',
78
+ serverURL: 'https://your-domain.vercel.app', // 替换为你的服务端地址,结尾勿带 /
79
+ color: '#ff9797' // 可选:自定义主题颜色
80
+ });
81
+ </script>
82
+ ```
83
+
84
+ ### 方法二:下载使用
85
+
86
+ 1. 下载 `static/css/likeme.css` 和 `static/js/likeme.js`
87
+ 2. 在你的项目中引入这些文件
88
+ 3. 按照上面的方式初始化组件
89
+
90
+ ### 配置选项
91
+
92
+ ```javascript
93
+ likeMe({
94
+ el: '#likeme', // 必需:容器选择器
95
+ serverURL: 'https://your-domain.vercel.app', // 必需:服务端地址,结尾勿带 /
96
+ color: '#ff9797' // 可选:主题颜色,默认为 #ff9797
97
+ });
98
+ ```
99
+
100
+ ## 本地开发
101
+
102
+ 环境:Node.js >= 18
103
+
104
+ ```bash
105
+ npm install
106
+ ```
107
+
108
+ 创建 `.env`:
109
+
110
+ ```bash
111
+ POSTGRES_URL=postgres://...
112
+ PORT=3000
113
+ ALLOW_ORIGIN=*
114
+ TRUST_PROXY=1
115
+ INFO_CACHE_SECONDS=5
116
+ LIKE_RATE_LIMIT_WINDOW_MS=60000
117
+ LIKE_RATE_LIMIT_MAX=20
118
+ ```
119
+
120
+ 启动项目:
121
+
122
+ ```bash
123
+ npm start
124
+ ```
125
+
126
+ - `PORT` 可选,默认 `3000`
127
+ - `ALLOW_ORIGIN` 可选,默认 `*`(如需限制跨域可填你的站点域名)
128
+ - `TRUST_PROXY` 默认 `1`,用于正确识别客户端 IP。
129
+ - `/info` 默认缓存 5 秒;`/like` 默认每 IP 每分钟 20 次。
130
+
131
+ ## API 接口
132
+
133
+ ### `GET /info`
134
+
135
+ 获取当前点赞数。
136
+
137
+ **响应格式:**
138
+
139
+ 成功:
140
+ ```json
141
+ {
142
+ "code": "200",
143
+ "msg": "success",
144
+ "data": {
145
+ "count": 123
146
+ }
147
+ }
148
+ ```
149
+
150
+ 失败:
151
+ ```json
152
+ {
153
+ "code": "201",
154
+ "msg": "哎呀,数据偷懒跑掉了"
155
+ }
156
+ ```
157
+
158
+ **缓存:** 响应头包含 `Cache-Control: public, max-age=5`(默认缓存 5 秒)
159
+
160
+ ### `GET /like`
161
+
162
+ 提交一次点赞。同一 IP 每天只能点赞一次。
163
+
164
+ **响应格式:**
165
+
166
+ 成功(首次点赞):
167
+ ```json
168
+ {
169
+ "code": "200",
170
+ "msg": "success",
171
+ "data": {
172
+ "count": 124
173
+ }
174
+ }
175
+ ```
176
+
177
+ 失败(今日已点赞):
178
+ ```json
179
+ {
180
+ "code": "201",
181
+ "msg": "今天的爱意已经收到啦~"
182
+ }
183
+ ```
184
+
185
+ 失败(系统错误):
186
+ ```json
187
+ {
188
+ "code": "201",
189
+ "msg": "你的爱意传递失败~"
190
+ }
191
+ ```
192
+
193
+ **限流:** 默认每 IP 每分钟最多 20 次请求
194
+
195
+ ### `GET /healthz`
196
+
197
+ 健康检查接口,用于检测服务是否正常运行。
198
+
199
+ **响应:** 纯文本 `ok`(HTTP 200)
package/demo/index.html CHANGED
@@ -1,21 +1,42 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="zh-CN">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
6
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Do You Like Me?</title>
8
- <link rel="stylesheet" href="https://unpkg.com/@5ime/likeme@latest/static/css/likeme.css">
6
+ <meta name="description" content="Do You Like Me? - 组件演示页面">
7
+ <meta name="keywords" content="点赞,互动,like,web组件,演示">
8
+ <title>Do You Like Me? - 演示页面</title>
9
+ <link rel="stylesheet" href="/static/css/likeme.css">
10
+ <style>
11
+ .container {
12
+ margin: 0 auto;
13
+ max-width: 400px;
14
+ width: 100%;
15
+ }
16
+
17
+ .demo-info {
18
+ text-align: center;
19
+ margin-bottom: 20px;
20
+ padding: 10px;
21
+ border-radius: 5px;
22
+ }
23
+ </style>
9
24
  </head>
10
25
  <body>
11
- <div id="likeme"></div>
26
+ <div class="container">
27
+ <div class="demo-info">
28
+ <h2>LikeMe 组件演示</h2>
29
+ <p>这是一个可交互的点赞组件演示页面</p>
30
+ </div>
31
+ <div id="likeme"></div>
32
+ </div>
12
33
 
13
- <script src="https://unpkg.com/@5ime/likeme@latest/static/js/likeme.js"></script>
34
+ <script src="/static/js/likeme.js"></script>
14
35
  <script>
15
36
  likeMe({
16
37
  el: '#likeme',
17
38
  serverURL: `${location.protocol}//${location.host}`,
18
- color: '#ff4e6a',
39
+ color: '#ff4e6a'
19
40
  });
20
41
  </script>
21
42
  </body>
package/index.js CHANGED
@@ -1,60 +1,14 @@
1
- const express = require('express');
2
- const AV = require('leancloud-storage');
3
-
4
- const app = express();
5
- const port = 80;
6
-
7
- AV.init({
8
- appId: process.env.appId,
9
- appKey: process.env.appKey,
10
- serverURL: process.env.serverURL
11
- });
12
-
13
- const account = AV.Object.createWithoutData('likeCount', process.env.objectId);
14
-
15
- app.use((req, res, next) => {
16
- res.header('Access-Control-Allow-Origin', '*');
17
- req.method === 'OPTIONS' ? res.sendStatus(200) : next();
18
- });
19
-
20
- app.use('/static', express.static(`${__dirname}/static`));
21
- app.use('/demo', express.static(`${__dirname}/demo`));
22
- app.get('/', (req, res) => res.sendFile(`${__dirname}/public/index.html`));
23
-
24
- app.get('/info', async (req, res) => {
25
- try {
26
- const count = (await new AV.Query('likeCount').first())?.get('count') || 0;
27
- res.json({ code: '200', msg: 'success', data: { count } });
28
- } catch {
29
- console.error('Error in /info:', error);
30
- res.json({ code: '201', msg: '数据拉取失败~' });
31
- }
32
- });
33
-
34
- app.get('/like', async (req, res) => {
35
- const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || 'unknown';
36
- try {
37
- const userExists = await new AV.Query('likeUser').equalTo('ip', ip).first();
38
- if (userExists) return res.json({ code: '201', msg: '今天的爱意已经收到啦~' });
39
-
40
- await account.fetch();
41
- account.increment('count');
42
- const updatedAccount = await account.save();
43
-
44
- const acl = new AV.ACL();
45
- acl.setPublicReadAccess(true);
46
- acl.setPublicWriteAccess(false);
47
-
48
- const likeUser = new (AV.Object.extend('likeUser'))();
49
- likeUser.set('ip', ip);
50
- likeUser.setACL(acl);
51
- await likeUser.save();
52
-
53
- res.json({ code: '200', msg: 'success', data: { count: updatedAccount.get('count') } });
54
- } catch (error) {
55
- console.error('Error in /like:', error);
56
- res.json({ code: '201', msg: '你的爱意传递失败~' });
57
- }
58
- });
59
-
60
- app.listen(port, () => console.log(`Server running on port ${port}`));
1
+ const { createApp } = require('./src/app');
2
+ const { port, isVercel } = require('./src/config');
3
+
4
+ const app = createApp();
5
+
6
+ if (isVercel) {
7
+ module.exports = app;
8
+ } else {
9
+ app.listen(port, () => {
10
+ console.log(`💖 启动成功!端口:${port}`);
11
+ console.log(`✨ 运行环境:${process.env.NODE_ENV || 'development'}`);
12
+ console.log(`🌍 访问地址:http://localhost:${port}`);
13
+ });
14
+ }
package/package.json CHANGED
@@ -1,10 +1,24 @@
1
- {
2
- "name": "@5ime/likeme",
3
- "version": "1.0.2",
4
- "description": "Do you like me?",
5
- "main": "index.js",
6
- "dependencies": {
7
- "express": "^4.18.1",
8
- "leancloud-storage": "^4.12.3"
9
- }
10
- }
1
+ {
2
+ "name": "@5ime/likeme",
3
+ "version": "1.0.4",
4
+ "description": "Do you like me? - 一个可爱的互动小组件",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "start": "node index.js",
8
+ "dev": "node index.js"
9
+ },
10
+ "author": "iami233",
11
+ "license": "MIT",
12
+ "dependencies": {
13
+ "@vercel/postgres": "^0.10.0",
14
+ "express": "^5.1.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/5ime/likeMe.git"
22
+ },
23
+ "homepage": "https://github.com/5ime/likeMe"
24
+ }
package/public/index.html CHANGED
@@ -1,18 +1,35 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Do You Like Me?</title>
8
- </head>
9
- <body>
10
- <link rel="stylesheet" href='https://unpkg.com/@5ime/likeme@latest/static/css/likeme.css' />
11
- <div class="likeCard">
12
- <div class="likeCard-text">
13
- 少女祈祷中...
14
- </div>
15
- <h3>Do You Like Me</h3>
16
- </div>
17
- </body>
18
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="description" content="Do You Like Me? - 一个可爱的互动点赞组件">
7
+ <title>Do You Like Me?</title>
8
+ <link rel="stylesheet" href="/static/css/likeme.css">
9
+ <style>
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
12
+ margin: 0;
13
+ min-height: 100vh;
14
+ display: flex;
15
+ justify-content: center;
16
+ align-items: center;
17
+ }
18
+
19
+ .container {
20
+ max-width: 400px;
21
+ width: 100%;
22
+ }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <div class="container">
27
+ <div class="likeCard">
28
+ <div class="likeCard-text">
29
+ 少女祈祷中...
30
+ </div>
31
+ <h3>Do You Like Me</h3>
32
+ </div>
33
+ </div>
34
+ </body>
35
+ </html>
package/src/app.js ADDED
@@ -0,0 +1,87 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+
4
+ const {
5
+ allowOrigin,
6
+ trustProxy,
7
+ infoCacheSeconds,
8
+ likeRateLimitMax,
9
+ likeRateLimitWindowMs,
10
+ } = require('./config');
11
+ const { getClientIp } = require('./ip');
12
+ const { getCurrentCount, likeOncePerDay } = require('./db');
13
+ const { ok, fail } = require('./http');
14
+ const { createIpRateLimiter } = require('./ratelimit');
15
+
16
+ function createApp() {
17
+ const app = express();
18
+
19
+ // 反代环境下正确识别客户端 IP(Vercel/CF 等)
20
+ app.set('trust proxy', trustProxy);
21
+
22
+ app.use((req, res, next) => {
23
+ res.setHeader('X-Content-Type-Options', 'nosniff');
24
+ res.setHeader('X-Frame-Options', 'DENY');
25
+ res.setHeader('Referrer-Policy', 'no-referrer');
26
+ res.setHeader(
27
+ 'Permissions-Policy',
28
+ 'geolocation=(), microphone=(), camera=()'
29
+ );
30
+ next();
31
+ });
32
+
33
+ app.use((req, res, next) => {
34
+ res.header('Access-Control-Allow-Origin', allowOrigin);
35
+ res.header('Access-Control-Allow-Headers', 'Content-Type');
36
+ res.header('Access-Control-Allow-Methods', 'GET,OPTIONS');
37
+ if (req.method === 'OPTIONS') return res.sendStatus(200);
38
+ next();
39
+ });
40
+
41
+ // 健康检查
42
+ app.get('/healthz', (req, res) => res.status(200).send('ok'));
43
+
44
+ // 静态资源
45
+ app.use('/static', express.static(path.join(__dirname, '..', 'static')));
46
+ app.use('/demo', express.static(path.join(__dirname, '..', 'demo')));
47
+ app.get('/', (req, res) =>
48
+ res.sendFile(path.join(__dirname, '..', 'public', 'index.html'))
49
+ );
50
+
51
+ app.get('/info', async (req, res) => {
52
+ try {
53
+ // 计数查询短缓存
54
+ res.setHeader('Cache-Control', `public, max-age=${infoCacheSeconds}`);
55
+ const count = await getCurrentCount();
56
+ return ok(res, { count });
57
+ } catch (error) {
58
+ console.error('Error in /info:', error?.message || 'Unknown error');
59
+ return fail(res, '哎呀,数据偷懒跑掉了');
60
+ }
61
+ });
62
+
63
+ // 点赞限流
64
+ const likeLimiter = createIpRateLimiter({
65
+ windowMs: likeRateLimitWindowMs,
66
+ max: likeRateLimitMax,
67
+ keyFn: (req) => getClientIp(req),
68
+ });
69
+
70
+ app.get('/like', likeLimiter, async (req, res) => {
71
+ const ip = getClientIp(req);
72
+ try {
73
+ res.setHeader('Cache-Control', 'no-store');
74
+ const result = await likeOncePerDay(ip);
75
+ if (!result.ok) return fail(res, '今天的爱意已经收到啦~');
76
+ return ok(res, { count: result.count });
77
+ } catch (error) {
78
+ console.error('Error in /like:', error?.message || 'Unknown error');
79
+ return fail(res, '你的爱意传递失败~');
80
+ }
81
+ });
82
+
83
+ return app;
84
+ }
85
+
86
+ module.exports = { createApp };
87
+
package/src/config.js ADDED
@@ -0,0 +1,16 @@
1
+ function env(name, fallback) {
2
+ const v = process.env[name];
3
+ if (v === undefined || v === null || v === '') return fallback;
4
+ return v;
5
+ }
6
+
7
+ module.exports = {
8
+ port: Number(env('PORT', '3000')),
9
+ allowOrigin: env('ALLOW_ORIGIN', '*'),
10
+ trustProxy: env('TRUST_PROXY', '1'),
11
+ infoCacheSeconds: Number(env('INFO_CACHE_SECONDS', '5')),
12
+ likeRateLimitWindowMs: Number(env('LIKE_RATE_LIMIT_WINDOW_MS', '60000')),
13
+ likeRateLimitMax: Number(env('LIKE_RATE_LIMIT_MAX', '20')),
14
+ isVercel: Boolean(process.env.VERCEL),
15
+ };
16
+
package/src/db.js ADDED
@@ -0,0 +1,93 @@
1
+ const { sql } = require('@vercel/postgres');
2
+
3
+ let schemaInitPromise = null;
4
+
5
+ async function ensureSchema() {
6
+ if (schemaInitPromise) return schemaInitPromise;
7
+
8
+ schemaInitPromise = (async () => {
9
+ // 总计数表
10
+ await sql`
11
+ CREATE TABLE IF NOT EXISTS like_count (
12
+ id INT PRIMARY KEY DEFAULT 1,
13
+ count BIGINT NOT NULL DEFAULT 0
14
+ );
15
+ `;
16
+ await sql`
17
+ INSERT INTO like_count (id, count)
18
+ VALUES (1, 0)
19
+ ON CONFLICT (id) DO NOTHING;
20
+ `;
21
+
22
+ // 去重表:同一 IP 每天一次(PRIMARY KEY (ip, day))
23
+ await sql`
24
+ CREATE TABLE IF NOT EXISTS like_user (
25
+ ip TEXT NOT NULL,
26
+ day DATE NOT NULL DEFAULT CURRENT_DATE,
27
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
28
+ PRIMARY KEY (ip, day)
29
+ );
30
+ `;
31
+ })();
32
+
33
+ return schemaInitPromise;
34
+ }
35
+
36
+ async function getCurrentCount() {
37
+ await ensureSchema();
38
+ const { rows } = await sql`SELECT count FROM like_count WHERE id = 1;`;
39
+ return Number(rows?.[0]?.count ?? 0);
40
+ }
41
+
42
+ async function likeOncePerDay(ip) {
43
+ await ensureSchema();
44
+
45
+ const client = await sql.connect();
46
+ try {
47
+ await client.sql`BEGIN;`;
48
+
49
+ const exists = await client.sql`
50
+ SELECT 1
51
+ FROM like_user
52
+ WHERE ip = ${ip} AND day = CURRENT_DATE
53
+ LIMIT 1;
54
+ `;
55
+
56
+ if (exists.rows.length > 0) {
57
+ await client.sql`ROLLBACK;`;
58
+ return { ok: false, reason: 'already_liked_today' };
59
+ }
60
+
61
+ await client.sql`
62
+ INSERT INTO like_user (ip, day)
63
+ VALUES (${ip}, CURRENT_DATE)
64
+ ON CONFLICT (ip, day) DO NOTHING;
65
+ `;
66
+
67
+ const updated = await client.sql`
68
+ UPDATE like_count
69
+ SET count = count + 1
70
+ WHERE id = 1
71
+ RETURNING count;
72
+ `;
73
+
74
+ await client.sql`COMMIT;`;
75
+ const count = Number(updated.rows?.[0]?.count ?? 0);
76
+ return { ok: true, count };
77
+ } catch (err) {
78
+ try {
79
+ await client.sql`ROLLBACK;`;
80
+ } catch (_) {
81
+ }
82
+ throw err;
83
+ } finally {
84
+ client.release();
85
+ }
86
+ }
87
+
88
+ module.exports = {
89
+ ensureSchema,
90
+ getCurrentCount,
91
+ likeOncePerDay,
92
+ };
93
+
package/src/http.js ADDED
@@ -0,0 +1,10 @@
1
+ function ok(res, data) {
2
+ return res.json({ code: '200', msg: 'success', data });
3
+ }
4
+
5
+ function fail(res, msg) {
6
+ return res.json({ code: '201', msg });
7
+ }
8
+
9
+ module.exports = { ok, fail };
10
+
package/src/ip.js ADDED
@@ -0,0 +1,23 @@
1
+ function sanitizeIp(ip) {
2
+ if (!ip || typeof ip !== 'string') return 'unknown';
3
+ // IPv6 最大长度 45
4
+ const cleaned = ip.trim().slice(0, 45);
5
+ if (!/^[a-fA-F0-9.:\-]+$/.test(cleaned)) return 'unknown';
6
+ return cleaned;
7
+ }
8
+
9
+ function getClientIp(req) {
10
+ const forwarded = req.headers['x-forwarded-for'];
11
+ if (forwarded) {
12
+ const ip = String(forwarded).split(',')[0].trim();
13
+ return sanitizeIp(ip);
14
+ }
15
+ const ip =
16
+ req.socket?.remoteAddress ||
17
+ req.connection?.remoteAddress ||
18
+ 'unknown';
19
+ return sanitizeIp(ip);
20
+ }
21
+
22
+ module.exports = { getClientIp, sanitizeIp };
23
+
@@ -0,0 +1,37 @@
1
+ function createIpRateLimiter({ windowMs, max, keyFn }) {
2
+ // key -> { count, resetAt }
3
+ const hits = new Map();
4
+
5
+ function cleanup(now) {
6
+ for (const [k, v] of hits) {
7
+ if (v.resetAt <= now) hits.delete(k);
8
+ }
9
+ }
10
+
11
+ return function rateLimit(req, res, next) {
12
+ const now = Date.now();
13
+ cleanup(now);
14
+
15
+ const key = (keyFn ? keyFn(req) : undefined) || 'unknown';
16
+ const existing = hits.get(key);
17
+
18
+ if (!existing || existing.resetAt <= now) {
19
+ hits.set(key, { count: 1, resetAt: now + windowMs });
20
+ return next();
21
+ }
22
+
23
+ existing.count += 1;
24
+ if (existing.count > max) {
25
+ res.setHeader(
26
+ 'Retry-After',
27
+ String(Math.ceil((existing.resetAt - now) / 1000))
28
+ );
29
+ return res.status(429).json({ code: '201', msg: '请求太频繁啦,稍后再试~' });
30
+ }
31
+
32
+ return next();
33
+ };
34
+ }
35
+
36
+ module.exports = { createIpRateLimiter };
37
+
@@ -1 +1 @@
1
- .likeCard{width:auto;user-select:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;cursor:pointer;border-radius:5px;margin:0 auto;padding:20px;background:#ff9797;transition:background .2s;color:#fff;text-align:center}.likeCard:hover{background:#ff8d8d}.likeCard>h3{font-size:1.2em;margin-top:.4em;margin-bottom:0}
1
+ .likeCard{width:auto;user-select:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;cursor:pointer;border-radius:5px;margin:0 auto;padding:20px;background:#ff9797;transition:all .3s ease;color:#fff;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}.likeCard:hover{background:#ff8d8d;transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.15)}.likeCard:active{transform:translateY(0px) scale(.98)}.likeCard>h3{font-size:1.2em;margin-top:.4em;margin-bottom:0;font-weight:600;transition:all .2s ease}.likeCard-text{font-size:1.1em;font-weight:500;transition:all .2s ease;min-height:1.5em;display:flex;align-items:center;justify-content:center}.likeCard.loading{pointer-events:none;opacity:.8;background:#ffa726}.likeCard.loading .likeCard-text{animation:pulse 1.2s infinite}.likeCard.success{background:#66bb6a;transform:scale(1.05)}@keyframes pulse{0%{opacity:1}50%{opacity:.6}100%{opacity:1}}
@@ -1 +1 @@
1
- const likeMe=(()=>{let e;return t=>e||(e=new LikeMeSingleton(t))})();class LikeMeSingleton{constructor({el:e,serverURL:t,color:r="#ff9797"}){Object.assign(this,{el:e,url:t,color:r}),this.init()}async init(){await this.renderUI(),this.attachEvents(),this.updateCount()}async renderUI(){try{const e=await fetch(this.url),t=await e.text(),r=document.querySelector(this.el);r&&(r.innerHTML=(new DOMParser).parseFromString(t,"text/html").body.innerHTML,r.querySelector(".likeCard")?.style.setProperty("background-color",this.color))}catch(e){console.error("Render UI error:",e)}}attachEvents(){document.querySelector(`${this.el} .likeCard`)?.addEventListener("click",(()=>this.handleLike()))}async updateCount(){try{const e=await fetch(`${this.url}/info`);if(e.ok){const t=(await e.json()).data?.count||0,r=document.querySelector(`${this.el} .likeCard-text`);r&&(r.textContent=`❤ ${t}`)}}catch(e){console.error("Update count error:",e)}}async handleLike(){try{const e=await fetch(`${this.url}/like`);if(e.ok){const{code:t,data:{count:r=0}={},msg:n}=await e.json(),o=document.querySelector(`${this.el} .likeCard-text`);o&&(o.textContent="200"===t?`❤ ${r}`:n)}}catch(e){console.error("Handle like error:",e)}}}
1
+ const likeMe=(()=>{let instance;return options=>instance||(instance=new LikeMeSingleton(options))})();class LikeMeSingleton{constructor({el,serverURL,color='#ff9797'}){Object.assign(this,{el,url:serverURL,color});this.isLiking=false;this.init()}async init(){try{await this.renderUI();this.attachEvents();this.updateCount()}catch(error){console.error('LikeMe init error:',error)}}async renderUI(){try{const response=await fetch(this.url);const html=await response.text();const element=document.querySelector(this.el);if(element){const parser=new DOMParser();const doc=parser.parseFromString(html,'text/html');element.innerHTML=doc.body.innerHTML;const card=element.querySelector('.likeCard');if(card){card.style.setProperty('background-color',this.color)}}}catch(error){console.error('Render UI error:',error)}}attachEvents(){const card=document.querySelector(`${this.el} .likeCard`);if(card){card.addEventListener('click',()=>this.handleLike())}}async updateCount(){try{const response=await fetch(`${this.url}/info`);const data=await response.json().catch(()=>null);if(response.ok&&data){const count=data.data?.count||0;const textElement=document.querySelector(`${this.el} .likeCard-text`);if(textElement){textElement.textContent=`❤ ${count}`}}}catch(error){console.error('Update count error:',error)}}setUIState(isLoading,text,isSuccess=false){const card=document.querySelector(`${this.el} .likeCard`);const textElement=document.querySelector(`${this.el} .likeCard-text`);if(card&&textElement){card.classList.remove('loading','success');if(isLoading){card.classList.add('loading')}else if(isSuccess){card.classList.add('success')}textElement.textContent=text}}async handleLike(){if(this.isLiking)return;this.isLiking=true;this.setUIState(true,'❤ 爱意传递中...');try{const response=await fetch(`${this.url}/like`);const payload=await response.json().catch(()=>null);if(!payload){this.setUIState(false,'❤ 网络错误');setTimeout(()=>{this.updateCount()},1500);return}const{code,data={},msg}=payload;const count=data.count||0;if(response.ok&&code==='200'){this.setUIState(false,'❤ 传递成功~',true);setTimeout(()=>{this.setUIState(false,`❤ ${count}`)},500);return}this.setUIState(false,msg||'❤ 传递失败~');setTimeout(()=>{this.updateCount()},1500)}catch(error){console.error('Handle like error:',error);this.setUIState(false,'❤ 网络错误');setTimeout(()=>{this.updateCount()},1500)}finally{this.isLiking=false}}}
package/vercel.json CHANGED
@@ -1,14 +1,14 @@
1
- {
2
- "builds": [
3
- {
4
- "src": "index.js",
5
- "use": "@vercel/node"
6
- }
7
- ],
8
- "routes": [
9
- {
10
- "src": "/(.*)",
11
- "dest": "index.js"
12
- }
13
- ]
14
- }
1
+ {
2
+ "builds": [
3
+ {
4
+ "src": "index.js",
5
+ "use": "@vercel/node"
6
+ }
7
+ ],
8
+ "routes": [
9
+ {
10
+ "src": "/(.*)",
11
+ "dest": "index.js"
12
+ }
13
+ ]
14
+ }