init: reinitialize from gin-template

This commit is contained in:
JustSong
2022-11-11 15:35:02 +08:00
parent 4063fe8c31
commit f1d5d9c8d1
129 changed files with 5629 additions and 2629 deletions

View File

@@ -1,7 +0,0 @@
.idea
node_modules
.env
data.db-journal
.vscode
*.db
*.*~

View File

@@ -1,2 +0,0 @@
PORT=3000
HREF="https://your.domain.com/"

View File

@@ -1,30 +0,0 @@
name: Build Client
on:
push:
tags:
- '*'
jobs:
release:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v3
- name: Build
run: |
cd client
go mod download
go build -ldflags "-s -w -extldflags '-static'" -o message-receiver.exe
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
client/message-receiver.exe
client/starter.vbs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,50 +0,0 @@
name: Publish Docker image
on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
justsong/message-pusher
ghcr.io/${{ github.repository }}
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

29
.github/workflows/github-pages.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Build GitHub Pages
on:
workflow_dispatch:
inputs:
name:
description: 'Reason'
required: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
with:
persist-credentials: false
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
env:
CI: ""
run: |
cd web
npm install
npm run build
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@releases/v3
with:
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
BRANCH: gh-pages # The branch the action should deploy to.
FOLDER: web/build # The folder the action should deploy.

40
.github/workflows/linux-release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Linux Release
on:
push:
tags:
- '*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'message-pusher/common.Version=$(git describe --tags)'" -o message-pusher
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: message-pusher
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

40
.github/workflows/macos-release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: macOS Release
on:
push:
tags:
- '*'
jobs:
release:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'message-pusher/common.Version=$(git describe --tags)'" -o message-pusher-macos
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: message-pusher-macos
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

43
.github/workflows/windows-release.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Windows Release
on:
push:
tags:
- '*'
jobs:
release:
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'message-pusher/common.Version=$(git describe --tags)'" -o message-pusher.exe
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: message-pusher.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9
.gitignore vendored
View File

@@ -1,9 +1,6 @@
.idea .idea
node_modules
.env
data.db-journal
.vscode .vscode
upload
*.exe
*.db *.db
package-lock.json build
yarn.lock
*.*~

View File

@@ -1,13 +1,25 @@
FROM node:16 as builder FROM node:16 as builder
WORKDIR /build WORKDIR /build
COPY . . COPY ./web .
RUN npm install RUN npm install
RUN npm run build
FROM golang AS builder2
ENV GO111MODULE=on \
CGO_ENABLED=1 \
GOOS=linux \
GOARCH=amd64
FROM node:16-alpine
WORKDIR /build WORKDIR /build
COPY --from=builder /build /build COPY . .
RUN npm install sqlite3@5.0.2 # https://github.com/TryGhost/node-sqlite3/issues/1581 COPY --from=builder /build/build ./web/build
RUN npm install pm2 -g RUN go mod download
RUN go build -ldflags "-s -w" -o message-pusher
FROM scratch
ENV PORT=3000
COPY --from=builder2 /build/message-pusher /
EXPOSE 3000 EXPOSE 3000
CMD ["pm2-runtime", "app.js"] ENTRYPOINT ["/message-pusher"]

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2020 JustSong Copyright (c) 2022 JustSong
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

143
README.md
View File

@@ -1,144 +1,3 @@
# 消息推送服务 # 消息推送服务
## 描述
1. 多种消息推送方式:
+ 使用微信公众号测试号推送,
+ 使用微信企业号推送,
+ 使用邮箱进行推送,
+ 使用专门的桌面客户端进行推送,消息直达你的电脑,需要额外安装一个非常小的客户端,[详见此处](./client/README.md)。
2. 支持 Markdown。
3. 支持部署在 Heroku 上,无需自己的服务器,[详见此处](#在-Heroku-上的搭建步骤)。
4. Docker 一键部署:`docker run -d -p 3000:3000 justsong/message-pusher`
## 用途举例 README 待重写。
1. [整合进自己的博客系统,每当有人登录时发微信消息提醒](https://github.com/songquanpeng/blog/blob/486d63e96ef7906a6c767653a20ec2d3278e9a4a/routes/user.js#L27)。
2. 在进行深度学习模型训练时,在每个 epoch 结束后[将关键数据发送到微信](https://github.com/songquanpeng/pytorch-template/blob/b2ba113659056080d3009b3014a67e977e2851bf/solver/solver.py#L223)以方便及时监控。
3. 在各种脚本运行结束后发消息提醒,例如[监控 Github Star 数量的脚本](https://github.com/songquanpeng/scripts/blob/main/star_watcher.py),又例如[自动健康填报的脚本](https://github.com/songquanpeng/daily-report),用来通知运行结果。
## 在自己的服务器上的部署步骤
### 域名设置
先去你的云服务提供商那里添加一个子域名,解析到你的目标服务器。
### 服务器端配置
#### 方式一:手动配置环境
1. 配置 Node.js 环境,推荐使用 [nvm](https://github.com/nvm-sh/nvm)。
2. 下载代码:`git clone https://github.com/songquanpeng/message-pusher.git`,或者 `git clone https://gitee.com/songquanpeng/message-pusher`
3. 修改根目录下的 config.js 文件:
+ (可选)可以修改监听的端口
+ (可选)配置是否选择开放注册
+ (必选)修改 href 字段,如 `https://pusher.yourdomain.com/`,注意后面要加 /,如果不修改此项,推送消息的详情页面将无法打开。
4. 安装依赖:`npm i`
5. 安装 pm2`npm i -g pm2`
6. 使用 pm2 启动服务:`pm2 start ./app.js --name message-pusher`
7. 使用 Nginx 反代我们的 Node.js 服务,默认端口 3000你可以在 config.js 中进行修改)。
1. 修改应用根目录下的 `nginx.conf` 中的域名以及端口号,并创建软链接:`sudo ln -s /path/to/nginx.conf /etc/nginx/sites-enabled/message-pusher.conf` **注意修改这里的 /path/to/nginx.conf且必须是绝对路径**,当然如果不想创建软链接的话也可以直接将配置文件拷贝过去:`sudo mv ./nginx.conf /etc/nginx/sites-enabled/message-pusher.conf`
2. 之后使用 [certbot](https://certbot.eff.org/lets-encrypt/ubuntuxenial-nginx) 申请证书:`sudo certbot --nginx`
3. 重启 Nginx 服务:`sudo service nginx restart`
8. 默认用户名密码为:`admin``123456`,且默认禁止新用户注册,如需修改,请编辑 `config.js`
#### 方式二:使用 Docker 进行部署
1. 执行命令:`docker run -d -p 3000:3000 justsong/message-pusher`
2. 接上面的第 7 步。
### 微信测试号配置
1. 首先前往[此页面](https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index)拿到 APP_ID 以及 APP_SECRET。
2. 使用微信扫描下方的测试号二维码,拿到你的 OPEN_ID。
3. 新增模板消息模板,模板标题随意,模板内容填 `{{text.DATA}}`,提交后可以拿到 TEMPLATE_ID。
4. 填写接口配置信息URL 填 `https://你的域名/前缀/verify`TOKEN 随意,先不要点击验证。(前缀默认和用户名相同)
5. 现在访问 `https://你的域名/`,默认用户为 admin默认密码为 123456登录后根据系统提示完成配置之后点击提交按钮。
6. 之后回到微信公众平台测试号的配置页面,点击验证。
### 微信企业号配置
1. 在该[页面](https://work.weixin.qq.com/)注册微信企业号(不需要企业资质)。
2. 在该[页面](https://work.weixin.qq.com/wework_admin/frame#profile)的最下方找到企业 ID。
3. 在该[页面](https://work.weixin.qq.com/wework_admin/frame#profile/wxPlugin)找到二维码,微信扫码关注。
4. 在该[页面](https://work.weixin.qq.com/wework_admin/frame#apps)创建一个应用,之后找到应用的 AgentId 和 Secret。
5. 在该[页面](https://work.weixin.qq.com/wework_admin/frame#contacts)找到你的个人账号(一般为你的姓名拼写)。
### 验证是否配置成功
访问 `https://你的域名/前缀/Hi`,如果你的微信能够收到一条内容为 Hi 的模板消息,则配置成功。
如果出现问题,请务必仔细检查所填信息是否正确。
如果出现 `无效的 access token` 的报错,说明你设置了 ACCESS_TOKEN 但是忘记在调用时传递该值或者传递的值是错的。
## 在 Heroku 上的搭建步骤
在此之前,请先读一下“在自己的服务器上的部署步骤”这一节。
由于 Heroku 的限制,当 30 分钟内没有请求的话就会被冻结,之后再次启动时数据就丢了,因此这里我们采用配置环境变量的方式进行配置,这样即使应用冻结后再次启动配置信息依然存在。
### 一键部署
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/songquanpeng/message-pusher)
### 手动部署
1. Fork 本项目。
2. 在[此处](https://dashboard.heroku.com/new-app)新建一个 Heroku APP名字随意之后可以设置自己的域名。
3. 在 Deployment method 处,选择 Connect to Github输入 message-pusher 搜索本项目,之后点击 Connect之后启用自动部署Enable Automatic Deploys
4. 点击上方的 Setting 标签,找到下面的 Config Vars 配置环境变量,有以下环境变量需要配置。
|KEY|VALUE|
|:--|:--|
|MODE|11 代表 Heroku 模式,该模式下应用从环境变量中读取必要信息)|
|PREFIX|你的前缀,如 admin前缀用于区分用户出现在请求的 api 路径中)|
|DEFAULT_METHOD|默认推送方式test 代表微信测试号corp 代表微信企业号email 代表邮件推送client 代表客户端推送)|
|HREF|服务的 href如 https://wechat-message.herokuapp.com/ ,注意后面要有 /|
|ACCESS_TOKEN|用于验证调用者身份,防止别人使用借口发送垃圾信息,置空则不进行检查,设置该值后则需要在调用时加上 token 字段|
|WECHAT_APP_ID|你的测试号的 APP ID|
|WECHAT_APP_SECRET|你的测试号的 APP Secret|
|WECHAT_TEMPLATE_ID|你的测试号的模板消息的 ID|
|WECHAT_OPEN_ID|你的 Open ID|
|WECHAT_VERIFY_TOKEN|你自己设置的验证 token|
|EMAIL|你的默认目标邮箱|
|SMTP_SERVER|smtp 服务器地址,如 smtp.qq.com|
|SMTP_USER|smtp 服务器用户邮箱|
|SMTP_PASS|smtp 服务器用户凭据|
|CORP_ID|微信企业号 ID|
|CORP_AGENT_ID|微信企业号应用 ID|
|CORP_APP_SECRET|微信企业号应用 Secret|
|CORP_USER_ID|微信企业号用户 ID|
## 发送消息的方式
1. 发送纯文本消息:直接 HTTP GET 请求 `https://你的域名/你的前缀/消息`,缺点是有字数限制,且只能是纯文本,这是微信消息的限制。
2. 发送 Markdown 消息,调用方式分为两种:
+ GET 请求方式:`https://你的域名/前缀/?&title=消息标题&description=简短的消息描述&content=markdown格式的消息内容&email=test@qq.com&token=private`
+ POST 请求方式:请求路径为 `https://你的域名/前缀/`,参数有:
1. `type`:(可选)发送方式
+ `test`:通过微信公众号测试号推送
+ `email`:通过发送邮件的方式进行推送
+ `corp`:通过微信企业号的应用号推送
+ `client`:通过桌面客户端推送
2. `title`:(可选)消息的标题
3. `description`:(必填)消息的描述
4. `content`:(可选)消息内容,支持 Markdown
5. `email`:(可选)当该项不为空时,将强制覆盖 type 参数,强制消息类型为邮件消息,收件邮箱即此处指定的邮箱。如果 type 为 `email` 且 email 参数为空,则邮件将发送至用户设置的默认邮箱。
6. `token`:(可选)如果你设置了 ACCESS_TOKEN则你需要附上该参数以验证身份。
## 示例程序
```python
import requests
# GET 方式
res = requests.get("https://push.iamazing.cn/admin/?title={}&description={}&token={}".format("标题", "描述", "666"))
# POST 方式
res = requests.post("https://your.domain.com/prefix/", data={
"title": "标题",
"description" : "描述",
"content": "**Markdown 内容**",
"token": "6666"
})
print(res.text)
# 输出为:{"success":true,"message":"ok"}
```
## 待做清单
- [x] 支持多用户。
- [ ] 完善的用户管理。
- [x] 支持 Markdown。
- [x] 支持推送消息到邮箱。
- [x] 支持在 Heroku 上部署。
- [x] 更加便于部署的 [Go 语言版本](https://github.com/LeeJiangWei/go-message)。
- [x] 适配企业微信应用。
- [x] 客户端推送。
敬请期待。

95
app.js
View File

@@ -1,95 +0,0 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const session = require('express-session');
const flash = require('connect-flash');
const rateLimit = require('express-rate-limit');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const http = require('http');
const serveStatic = require('serve-static');
const config = require('./config');
const indexRouter = require('./routers/index');
const messageRouter = require('./routers/message');
const userRouter = require('./routers/user');
const { refreshToken } = require('./common/wechat');
const { initializeTokenStore, registerWebSocket } = require('./common/token');
const app = express();
const WebSocket = require('ws');
app.locals.isLogged = false;
app.locals.isAdmin = false;
app.locals.message = '';
app.locals.isErrorMessage = false;
setTimeout(async () => {
// TODO: Here we need an improvement! I have tried EventEmitter but it's not working. :(
await initializeTokenStore();
await refreshToken();
setInterval(async () => refreshToken(), 100 * 60 * 1000);
}, 1000);
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('trust proxy', true);
app.use(
rateLimit({
windowMs: 30 * 1000,
max: 30
})
);
app.use(
'/login',
rateLimit({
windowMs: 60 * 1000,
max: 5
})
);
app.use(compression());
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(config.cookie_secret));
app.use(
session({
resave: true,
saveUninitialized: true,
secret: config.session_secret
})
);
app.use(flash());
app.use(express.static(path.join(__dirname, 'public')));
app.use(
'/public',
serveStatic(path.join(__dirname, `public`), {
maxAge: '600000'
})
);
app.use('*', (req, res, next) => {
if (req.session.user !== undefined) {
res.locals.isLogged = true;
res.locals.isAdmin = req.session.user.isAdmin;
}
next();
});
app.use('/message', messageRouter);
app.use('/', indexRouter);
app.use('/', userRouter);
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
server.listen(config.port);
wss.on('connection', (ws) => {
ws.on('message', (data) => {
let message = JSON.parse(data.toString());
if (message.prefix) {
registerWebSocket(message.prefix, message.token, ws);
}
});
});
module.exports = app;

View File

@@ -1,84 +0,0 @@
{
"name": "message-pusher",
"description": "消息推送服务",
"env": {
"MODE": {
"description": "Heroku 模式(请保持默认)",
"value": "1"
},
"WECHAT_APP_ID": {
"description": "你的测试号的 APP ID",
"value": ""
},
"WECHAT_APP_SECRET": {
"description": "你的测试号的 APP Secret",
"value": ""
},
"WECHAT_TEMPLATE_ID": {
"description": "你的测试号的模板消息的 ID",
"value": ""
},
"WECHAT_OPEN_ID": {
"description": "你的 Open ID (扫描测试号二维码,列表中显示的微信号)",
"value": ""
},
"WECHAT_VERIFY_TOKEN": {
"description": "你自己设置的验证 token",
"value": ""
},
"EMAIL": {
"description": "你的默认目标邮箱",
"value": "",
"required" : false
},
"PREFIX": {
"description": "你的前缀,如 admin",
"value": "admin"
},
"SMTP_SERVER": {
"description": "smtp 服务器地址,如 smtp.qq.com",
"value": "smtp.qq.com",
"required" : false
},
"SMTP_USER": {
"description": "smtp 服务器用户邮箱",
"value": "",
"required" : false
},
"SMTP_PASS": {
"description": "smtp 服务器用户凭据",
"value": "",
"required" : false
},
"HREF": {
"description": "服务的 href如 https://wechat-message.herokuapp.com/ ,注意后面要有 /",
"value": ""
},
"DEFAULT_METHOD" : {
"description": "默认方式方式test 代表微信测试号cor 代表微信企业号email 代表邮件推送)",
"value": ""
},
"CORP_ID" : {
"description": "微信企业号 ID",
"value": ""
},
"CORP_AGENT_ID" : {
"description": "微信企业号应用 ID",
"value": ""
},
"CORP_APP_SECRET" : {
"description": "微信企业号应用 Secret",
"value": ""
},
"CORP_USER_ID" : {
"description": "微信企业号用户 ID",
"value": ""
},
"ACCESS_TOKEN" : {
"description": "用于验证调用者身份,防止别人使用借口发送垃圾信息,置空则不进行检查,设置该值后则需要在调用时加上 token 字段",
"value": ""
}
},
"website": "https://github.com/songquanpeng/message-pusher",
"repository": "https://github.com/songquanpeng/message-pusher"
}

2
client/.gitignore vendored
View File

@@ -1,2 +0,0 @@
.idea
*.exe

View File

@@ -1,18 +0,0 @@
# Message Pusher 桌面客户端
## 描述
该客户端用于支持 `client` 消息推送方式。
注意,为防止未授权的 WebSocket 连接,推荐设置 ACCESS_TOKEN。
启动参数:`./client.exe --url http://your.domain.com:port/prefix --token private`
也可以通过设置环境变量来传递配置:`MESSAGE_PUSHER_URL` & `MESSAGE_PUSHER_TOKEN`
## 原理
客户端启动后与 message-pusher 服务器建立一个 WebSocket 连接,通过该连接接受要推送消息,并调用系统的消息通知接口进行消息通知。
## 安装
1. 首先前往 [Release 页面](https://github.com/songquanpeng/message-pusher/releases)下载可执行文件和 vbs 脚本文件(该脚本文件目的是为了隐藏窗口)。
2. 编辑 vbs 脚本文件,将其中的可执行文件的路径和后面的参数改成符合你的情况的值。
3. 之后对 vbs 脚本文件右键创建快捷方式。
5. 之后把该快捷方式放到开机启动文件夹内:`C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp`

View File

@@ -1,11 +0,0 @@
module client
go 1.16
require (
github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1 // indirect
github.com/godbus/dbus/v5 v5.0.4 // indirect
github.com/gopherjs/gopherjs v0.0.0-20210603182125-eeedf4a0e899 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/sys v0.0.0-20210611083646-a4fc73990273 // indirect
)

View File

@@ -1,330 +0,0 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1 h1:Xh9mvwEmhbdXlRSsgn+N0zj/NqnKvpeqL08oKDHln2s=
github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1/go.mod h1:ElSskYZe3oM8kThaHGJ+kiN2yyUMVXMZ7WxF9QqLDS8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20210603182125-eeedf4a0e899 h1:3RAjDwfQm52SdfOxlHfcstP1mNPKf7IvIWn/I4ENlFQ=
github.com/gopherjs/gopherjs v0.0.0-20210603182125-eeedf4a0e899/go.mod h1:MtKwTfDNYAP5EtbQSMYjTSqvj1aXJKQRASWq3bwaP+g=
github.com/gopherjs/gopherwasm v1.1.0 h1:fA2uLoctU5+T3OhOn2vYP0DVT6pxc7xhTlBB1paATqQ=
github.com/gopherjs/gopherwasm v1.1.0/go.mod h1:SkZ8z7CWBz5VXbhJel8TxCmAcsQqzgWGR/8nMhyhZSI=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210611083646-a4fc73990273 h1:faDu4veV+8pcThn4fewv6TVlNCezafGoC1gM/mxQLbQ=
golang.org/x/sys v0.0.0-20210611083646-a4fc73990273/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=

View File

@@ -1,92 +0,0 @@
package main
import (
"flag"
"github.com/gorilla/websocket"
"log"
"net/url"
"os"
"time"
)
var (
conn = flag.String("url", "", "connection url")
token = flag.String("token", "", "the access token")
)
type Verification struct {
Prefix string `json:"prefix"`
Token string `json:"token"`
}
type Ping struct {
}
func main() {
flag.Parse()
connString := *conn
if connString == "" {
connString = os.Getenv("MESSAGE_PUSHER_URL")
}
if *token == "" {
*token = os.Getenv("MESSAGE_PUSHER_TOKEN")
}
connUrl, err := url.Parse(connString)
if err != nil {
log.Fatal("Failed to parse connection url", err)
return
}
scheme := "ws"
if connUrl.Scheme == "https" {
scheme = "wss"
}
u := url.URL{Scheme: scheme, Host: connUrl.Host, Path: "/"}
verification := &Verification{
Prefix: connUrl.Path[1:],
Token: *token,
}
ping := &Ping{}
ticker := time.NewTicker(60 * time.Second)
for {
log.Printf("Connecting to %s...\n", u.String())
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Fatal("Failed to connect to server:", err)
return
}
log.Printf("Server connected.\n")
err = c.WriteJSON(verification)
if err != nil {
log.Fatal(err.Error())
}
go func() {
for {
select {
case <-ticker.C:
if err := c.WriteJSON(ping); err != nil {
log.Println("Error occurred when send ping message:", err)
log.Println("Connection lost, retrying...")
_ = c.Close()
break
} else {
log.Println("Ping message sent.")
}
}
}
}()
for {
var message = new(Message)
err = c.ReadJSON(message)
if err != nil {
log.Println("Error occurred when read message:", err)
log.Println("Connection lost, retrying...")
_ = c.Close()
break
} else {
log.Println("New message arrived.")
Notify(message)
}
}
}
}

View File

@@ -1,18 +0,0 @@
package main
import (
"fmt"
"github.com/gen2brain/beeep"
)
type Message struct {
Title string
Description string
}
func Notify(message *Message) {
err := beeep.Notify(message.Title, message.Description, "assets/information.png")
if err != nil {
fmt.Println(err)
}
}

View File

@@ -1,3 +0,0 @@
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run chr(34) & "C:\Users\song\Projects\message-pusher\client\message-receiver.exe ""--url https://server_host/prefix" & Chr(34), 0
Set WshShell = Nothing

View File

@@ -1,25 +0,0 @@
const WebSocket = require('ws');
const { tokenStore } = require('./token');
async function pushClientMessage(userPrefix, message) {
let user = tokenStore.get(userPrefix);
if (!user || !user.ws || user.ws.readyState !== WebSocket.OPEN) {
return {
success: false,
message: `客户端未连接`,
};
}
let data = {
title: message.title,
description: message.description,
};
user.ws.send(JSON.stringify(data));
return {
success: true,
message: '消息已发送至客户端',
};
}
module.exports = {
pushClientMessage,
};

80
common/constants.go Normal file
View File

@@ -0,0 +1,80 @@
package common
import (
"github.com/google/uuid"
"sync"
"time"
)
var StartTime = time.Now().Unix() // unit: second
var Version = "v0.0.0"
var SystemName = "消息推送服务"
var ServerAddress = "http://localhost:3000"
var Footer = ""
// Any options with "Secret", "Token" in its key won't be return by GetOptions
var SessionSecret = uuid.New().String()
var SQLitePath = ".message-pusher.db"
var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex
var ItemsPerPage = 10
var PasswordLoginEnabled = true
var PasswordRegisterEnabled = true
var EmailVerificationEnabled = false
var GitHubOAuthEnabled = false
var WeChatAuthEnabled = false
var SMTPServer = ""
var SMTPAccount = ""
var SMTPToken = ""
var GitHubClientId = ""
var GitHubClientSecret = ""
var WeChatServerAddress = ""
var WeChatServerToken = ""
var WeChatAccountQRCodeImageURL = ""
const (
RoleGuestUser = 0
RoleCommonUser = 1
RoleAdminUser = 10
RoleRootUser = 100
)
var (
FileUploadPermission = RoleGuestUser
FileDownloadPermission = RoleGuestUser
ImageUploadPermission = RoleGuestUser
ImageDownloadPermission = RoleGuestUser
)
// All duration's unit is seconds
// Shouldn't larger then RateLimitKeyExpirationDuration
var (
GlobalApiRateLimitNum = 60
GlobalApiRateLimitDuration int64 = 3 * 60
GlobalWebRateLimitNum = 60
GlobalWebRateLimitDuration int64 = 3 * 60
UploadRateLimitNum = 10
UploadRateLimitDuration int64 = 60
DownloadRateLimitNum = 10
DownloadRateLimitDuration int64 = 60
CriticalRateLimitNum = 20
CriticalRateLimitDuration int64 = 20 * 60
)
var RateLimitKeyExpirationDuration = 20 * time.Minute
const (
UserStatusEnabled = 1 // don't use 0, 0 is the default value!
UserStatusDisabled = 2 // also don't use 0
)

14
common/crypto.go Normal file
View File

@@ -0,0 +1,14 @@
package common
import "golang.org/x/crypto/bcrypt"
func Password2Hash(password string) (string, error) {
passwordBytes := []byte(password)
hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
return string(hashedPassword), err
}
func ValidatePasswordAndHash(password string, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@@ -1,9 +0,0 @@
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: 'data.db',
logging: false,
});
module.exports = sequelize;

14
common/email.go Normal file
View File

@@ -0,0 +1,14 @@
package common
import "gopkg.in/gomail.v2"
func SendEmail(subject string, receiver string, content string) error {
m := gomail.NewMessage()
m.SetHeader("From", SMTPAccount)
m.SetHeader("To", receiver)
m.SetHeader("Subject", subject)
m.SetBody("text/html", content)
d := gomail.NewDialer(SMTPServer, 587, SMTPAccount, SMTPToken)
err := d.DialAndSend(m)
return err
}

View File

@@ -1,51 +0,0 @@
const nodemailer = require('nodemailer');
const { tokenStore } = require('./token');
const config = require('../config');
const { md2html } = require('./utils');
async function pushEmailMessage(userPrefix, message) {
let user = tokenStore.get(userPrefix);
if (!user) {
return {
success: false,
message: `不存在的前缀:${userPrefix},请注意大小写`,
};
}
let transporter = nodemailer.createTransport({
host: user.smtpServer,
secure: true,
auth: {
user: user.smtpUser,
pass: user.smtpPass,
},
});
let targetEmail = user.email;
if (message.email) {
targetEmail = message.email;
}
try {
await transporter.sendMail({
from: `"消息推送服务" <${user.smtpUser}>`,
to: targetEmail,
subject: message.description,
text: message.content,
html: md2html(message.content),
});
return {
success: true,
message: 'ok',
};
} catch (e) {
console.error(e);
return {
success: false,
message: e.message,
};
}
}
module.exports = {
pushEmailMessage,
};

View File

@@ -0,0 +1,32 @@
package common
import (
"embed"
"github.com/gin-gonic/contrib/static"
"io/fs"
"net/http"
)
// Credit: https://github.com/gin-contrib/static/issues/19
type embedFileSystem struct {
http.FileSystem
}
func (e embedFileSystem) Exists(prefix string, path string) bool {
_, err := e.Open(path)
if err != nil {
return false
}
return true
}
func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
efs, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
panic(err)
}
return embedFileSystem{
FileSystem: http.FS(efs),
}
}

78
common/init.go Normal file
View File

@@ -0,0 +1,78 @@
package common
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
)
var (
Port = flag.Int("port", 3000, "the listening port")
PrintVersion = flag.Bool("version", false, "print version and exit")
LogDir = flag.String("log-dir", "", "specify the log directory")
//Host = flag.String("host", "localhost", "the server's ip address or domain")
//Path = flag.String("path", "", "specify a local path to public")
//VideoPath = flag.String("video", "", "specify a video folder to public")
//NoBrowser = flag.Bool("no-browser", false, "open browser or not")
)
// UploadPath Maybe override by ENV_VAR
var UploadPath = "upload"
//var ExplorerRootPath = UploadPath
//var ImageUploadPath = "upload/images"
//var VideoServePath = "upload"
func init() {
flag.Parse()
if *PrintVersion {
fmt.Println(Version)
os.Exit(0)
}
if os.Getenv("SESSION_SECRET") != "" {
SessionSecret = os.Getenv("SESSION_SECRET")
}
if os.Getenv("SQLITE_PATH") != "" {
SQLitePath = os.Getenv("SQLITE_PATH")
}
if os.Getenv("UPLOAD_PATH") != "" {
UploadPath = os.Getenv("UPLOAD_PATH")
//ExplorerRootPath = UploadPath
//ImageUploadPath = path.Join(UploadPath, "images")
//VideoServePath = UploadPath
}
if *LogDir != "" {
var err error
*LogDir, err = filepath.Abs(*LogDir)
if err != nil {
log.Fatal(err)
}
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
err = os.Mkdir(*LogDir, 0777)
if err != nil {
log.Fatal(err)
}
}
}
//if *Path != "" {
// ExplorerRootPath = *Path
//}
//if *VideoPath != "" {
// VideoServePath = *VideoPath
//}
//
//ExplorerRootPath, _ = filepath.Abs(ExplorerRootPath)
//VideoServePath, _ = filepath.Abs(VideoServePath)
//ImageUploadPath, _ = filepath.Abs(ImageUploadPath)
//
if _, err := os.Stat(UploadPath); os.IsNotExist(err) {
_ = os.Mkdir(UploadPath, 0777)
}
//if _, err := os.Stat(ImageUploadPath); os.IsNotExist(err) {
// _ = os.Mkdir(ImageUploadPath, 0777)
//}
}

39
common/logger.go Normal file
View File

@@ -0,0 +1,39 @@
package common
import (
"fmt"
"github.com/gin-gonic/gin"
"io"
"log"
"os"
"path/filepath"
"time"
)
func SetupGinLog() {
if *LogDir != "" {
commonLogPath := filepath.Join(*LogDir, "common.log")
errorLogPath := filepath.Join(*LogDir, "error.log")
commonFd, err := os.OpenFile(commonLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal("failed to open log file")
}
errorFd, err := os.OpenFile(errorLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal("failed to open log file")
}
gin.DefaultWriter = io.MultiWriter(os.Stdout, commonFd)
gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, errorFd)
}
}
func SysLog(s string) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
}
func FatalLog(v ...any) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
os.Exit(1)
}

View File

@@ -1,62 +0,0 @@
const {
getUserDefaultMethod,
checkAccessToken,
checkPrefix,
} = require('./token');
const { pushWeChatMessage } = require('./wechat');
const { pushWeChatCorpMessage } = require('./wechat-corp');
const { pushEmailMessage } = require('./email');
const { pushClientMessage } = require('./client');
const { Message } = require('../models');
async function processMessage(userPrefix, message) {
if (!checkPrefix(userPrefix)) {
return {
success: false,
message: `不存在的用户前缀:${userPrefix}`,
};
}
if (!checkAccessToken(userPrefix, message.token)) {
return {
success: false,
message: `无效的访问凭证,请检查 token 参数是否正确`,
};
}
if (message.email) {
// If message has the attribute "email", override its type.
message.type = 'email';
}
if (!message.type) {
message.type = getUserDefaultMethod(userPrefix);
}
if (message.content && message.type !== 'email') {
// If message is not email type, we should save it because we have to serve the page.
message = await Message.create(message, { raw: true });
}
let result;
switch (message.type) {
case 'test': // WeChat message
result = await pushWeChatMessage(userPrefix, message);
break;
case 'email': // Email message
result = await pushEmailMessage(userPrefix, message);
break;
case 'corp': // WeChat corp message
result = await pushWeChatCorpMessage(userPrefix, message);
break;
case 'client': // Client message
result = await pushClientMessage(userPrefix, message);
break;
default:
result = {
success: false,
message: `不支持的消息类型:${message.type}`,
};
break;
}
return result;
}
module.exports = {
processMessage,
};

70
common/rate-limit.go Normal file
View File

@@ -0,0 +1,70 @@
package common
import (
"sync"
"time"
)
type InMemoryRateLimiter struct {
store map[string]*[]int64
mutex sync.Mutex
expirationDuration time.Duration
}
func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
if l.store == nil {
l.mutex.Lock()
if l.store == nil {
l.store = make(map[string]*[]int64)
l.expirationDuration = expirationDuration
if expirationDuration > 0 {
go l.clearExpiredItems()
}
}
l.mutex.Unlock()
}
}
func (l *InMemoryRateLimiter) clearExpiredItems() {
for {
time.Sleep(l.expirationDuration)
l.mutex.Lock()
now := time.Now().Unix()
for key := range l.store {
queue := l.store[key]
size := len(*queue)
if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
delete(l.store, key)
}
}
l.mutex.Unlock()
}
}
// Request parameter duration's unit is seconds
func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
l.mutex.Lock()
defer l.mutex.Unlock()
// [old <-- new]
queue, ok := l.store[key]
now := time.Now().Unix()
if ok {
if len(*queue) < maxRequestNum {
*queue = append(*queue, now)
return true
} else {
if now-(*queue)[0] >= duration {
*queue = (*queue)[1:]
*queue = append(*queue, now)
return true
} else {
return false
}
}
} else {
s := make([]int64, 0, maxRequestNum)
l.store[key] = &s
*(l.store[key]) = append(*(l.store[key]), now)
}
return true
}

39
common/redis.go Normal file
View File

@@ -0,0 +1,39 @@
package common
import (
"context"
"github.com/go-redis/redis/v8"
"os"
"time"
)
var RDB *redis.Client
var RedisEnabled = true
// InitRedisClient This function is called after init()
func InitRedisClient() (err error) {
if os.Getenv("REDIS_CONN_STRING") == "" {
RedisEnabled = false
SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
return nil
}
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
if err != nil {
panic(err)
}
RDB = redis.NewClient(opt)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = RDB.Ping(ctx).Result()
return err
}
func ParseRedisOption() *redis.Options {
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
if err != nil {
panic(err)
}
return opt
}

View File

@@ -1,109 +0,0 @@
const { User } = require('../models');
const tokenStore = new Map();
async function initializeTokenStore() {
let users = [];
if (process.env.MODE === '1') {
console.log('Current mode is Heroku mode.');
let user = {
// Common
prefix: process.env.PREFIX,
accessToken: process.env.ACCESS_TOKEN,
defaultMethod: process.env.DEFAULT_METHOD,
// WeChat public account
wechatAppId: process.env.WECHAT_APP_ID,
wechatAppSecret: process.env.WECHAT_APP_SECRET,
wechatTemplateId: process.env.WECHAT_TEMPLATE_ID,
wechatOpenId: process.env.WECHAT_OPEN_ID,
wechatVerifyToken: process.env.WECHAT_VERIFY_TOKEN,
// Email
email: process.env.EMAIL,
smtpServer: process.env.SMTP_SERVER,
smtpUser: process.env.SMTP_USER,
smtpPass: process.env.SMTP_PASS,
// WeChat corporation account
corpId: process.env.CORP_ID,
corpAgentId: process.env.CORP_AGENT_ID,
corpAppSecret: process.env.CORP_APP_SECRET,
corpUserId: process.env.CORP_USER_ID,
};
users.push(user);
} else {
users = await User.findAll({
raw: true,
});
}
users.forEach((user) => {
if (user.prefix) {
tokenStore.set(user.prefix, {
// Common
accessToken: user.accessToken,
defaultMethod: user.defaultMethod,
// WeChat test account
wechatAppId: user.wechatAppId,
wechatAppSecret: user.wechatAppSecret,
wechatTemplateId: user.wechatTemplateId,
wechatOpenId: user.wechatOpenId,
wechatVerifyToken: user.wechatVerifyToken,
wechatToken: '',
// Email
email: user.email,
smtpServer: user.smtpServer,
smtpUser: user.smtpUser,
smtpPass: user.smtpPass,
// WeChat corporation account
corpId: user.corpId,
corpAgentId: user.corpAgentId,
corpAppSecret: user.corpAppSecret,
corpUserId: user.corpUserId,
corpToken: '',
});
}
});
console.log('Token store initialized.');
}
function updateTokenStore(prefix, key, value) {
let user = tokenStore.get(prefix);
user[key] = value;
tokenStore.set(prefix, user);
}
function getUserDefaultMethod(prefix) {
let user = tokenStore.get(prefix);
return user.defaultMethod;
}
function checkAccessToken(prefix, token) {
let user = tokenStore.get(prefix);
if (user.accessToken === '') {
return true;
} else {
return user.accessToken === token;
}
}
function checkPrefix(prefix) {
let user = tokenStore.get(prefix);
return user !== undefined;
}
function registerWebSocket(prefix, token, ws) {
let user = tokenStore.get(prefix);
if (user && user.accessToken === token) {
updateTokenStore(prefix, 'ws', ws);
} else {
ws.terminate();
}
}
module.exports = {
initializeTokenStore,
updateTokenStore,
getUserDefaultMethod,
tokenStore,
checkAccessToken,
checkPrefix,
registerWebSocket,
};

133
common/utils.go Normal file
View File

@@ -0,0 +1,133 @@
package common
import (
"fmt"
"github.com/google/uuid"
"html/template"
"log"
"net"
"os/exec"
"runtime"
"strconv"
"strings"
)
func OpenBrowser(url string) {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
}
if err != nil {
log.Println(err)
}
}
func GetIp() (ip string) {
ips, err := net.InterfaceAddrs()
if err != nil {
log.Println(err)
return ip
}
for _, a := range ips {
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
ip = ipNet.IP.String()
if strings.HasPrefix(ip, "10") {
return
}
if strings.HasPrefix(ip, "172") {
return
}
if strings.HasPrefix(ip, "192.168") {
return
}
ip = ""
}
}
}
return
}
var sizeKB = 1024
var sizeMB = sizeKB * 1024
var sizeGB = sizeMB * 1024
func Bytes2Size(num int64) string {
numStr := ""
unit := "B"
if num/int64(sizeGB) > 1 {
numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
unit = "GB"
} else if num/int64(sizeMB) > 1 {
numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB)))
unit = "MB"
} else if num/int64(sizeKB) > 1 {
numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB)))
unit = "KB"
} else {
numStr = fmt.Sprintf("%d", num)
}
return numStr + " " + unit
}
func Seconds2Time(num int) (time string) {
if num/31104000 > 0 {
time += strconv.Itoa(num/31104000) + " 年 "
num %= 31104000
}
if num/2592000 > 0 {
time += strconv.Itoa(num/2592000) + " 个月 "
num %= 2592000
}
if num/86400 > 0 {
time += strconv.Itoa(num/86400) + " 天 "
num %= 86400
}
if num/3600 > 0 {
time += strconv.Itoa(num/3600) + " 小时 "
num %= 3600
}
if num/60 > 0 {
time += strconv.Itoa(num/60) + " 分钟 "
num %= 60
}
time += strconv.Itoa(num) + " 秒"
return
}
func Interface2String(inter interface{}) string {
switch inter.(type) {
case string:
return inter.(string)
case int:
return fmt.Sprintf("%d", inter.(int))
case float64:
return fmt.Sprintf("%f", inter.(float64))
}
return "Not Implemented"
}
func UnescapeHTML(x string) interface{} {
return template.HTML(x)
}
func IntMax(a int, b int) int {
if a >= b {
return a
} else {
return b
}
}
func GetUUID() string {
code := uuid.New().String()
code = strings.Replace(code, "-", "", -1)
return code
}

View File

@@ -1,13 +0,0 @@
const lexer = require('marked').lexer;
const parser = require('marked').parser;
function md2html(markdown) {
if (markdown) {
return parser(lexer(markdown));
}
return markdown;
}
module.exports = {
md2html,
};

9
common/validate.go Normal file
View File

@@ -0,0 +1,9 @@
package common
import "github.com/go-playground/validator/v10"
var Validate *validator.Validate
func init() {
Validate = validator.New()
}

77
common/verification.go Normal file
View File

@@ -0,0 +1,77 @@
package common
import (
"github.com/google/uuid"
"strings"
"sync"
"time"
)
type verificationValue struct {
code string
time time.Time
}
const (
EmailVerificationPurpose = "v"
PasswordResetPurpose = "r"
)
var verificationMutex sync.Mutex
var verificationMap map[string]verificationValue
var verificationMapMaxSize = 10
var VerificationValidMinutes = 10
func GenerateVerificationCode(length int) string {
code := uuid.New().String()
code = strings.Replace(code, "-", "", -1)
if length == 0 {
return code
}
return code[:length]
}
func RegisterVerificationCodeWithKey(key string, code string, purpose string) {
verificationMutex.Lock()
defer verificationMutex.Unlock()
verificationMap[purpose+key] = verificationValue{
code: code,
time: time.Now(),
}
if len(verificationMap) > verificationMapMaxSize {
removeExpiredPairs()
}
}
func VerifyCodeWithKey(key string, code string, purpose string) bool {
verificationMutex.Lock()
defer verificationMutex.Unlock()
value, okay := verificationMap[purpose+key]
now := time.Now()
if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
return false
}
return code == value.code
}
func DeleteKey(key string, purpose string) {
verificationMutex.Lock()
defer verificationMutex.Unlock()
delete(verificationMap, purpose+key)
}
// no lock inside!
func removeExpiredPairs() {
now := time.Now()
for key := range verificationMap {
if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {
delete(verificationMap, key)
}
}
}
func init() {
verificationMutex.Lock()
defer verificationMutex.Unlock()
verificationMap = make(map[string]verificationValue)
}

View File

@@ -1,100 +0,0 @@
const axios = require('axios');
const { tokenStore, updateTokenStore } = require('./token');
const config = require('../config');
async function refreshToken() {
for (let [key, value] of tokenStore) {
if (value.corpId) {
value.corpToken = await requestToken(value.corpId, value.corpAppSecret);
tokenStore.set(key, value);
}
}
console.log('Token refreshed.');
}
async function requestToken(corpId, corpAppSecret) {
// Reference: https://work.weixin.qq.com/api/doc/90000/90135/91039
let token = '';
try {
let res = await axios.get(
`https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpAppSecret}`
);
// console.debug(res);
if (res && res.data) {
if (res.data.access_token) {
token = res.data.access_token;
} else {
console.error(res.data);
}
}
} catch (e) {
console.error(e);
}
return token;
}
async function pushWeChatCorpMessage(userPrefix, message) {
// Reference: https://work.weixin.qq.com/api/doc/90000/90135/90236
let user = tokenStore.get(userPrefix);
if (!user) {
return {
success: false,
message: `不存在的前缀:${userPrefix},请注意大小写`,
};
}
let access_token = user.corpToken;
let request_data = {
msgtype: 'textcard',
touser: user.corpUserId,
agentid: user.corpAgentId,
textcard: {
title: message.title,
description: message.description,
},
};
if (message.content) {
request_data.textcard.url = `${config.href}message/${message.id}`;
} else {
request_data.textcard.url = `${config.href}`;
}
let requestUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${access_token}`;
try {
let response = await axios.post(requestUrl, request_data);
if (response && response.data && response.data.errcode !== 0) {
// Failed to push message, get a new token and try again.
access_token = await requestToken(user.corpId, user.corpAppSecret);
updateTokenStore(userPrefix, 'corpToken', access_token);
requestUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${access_token}`;
response = await axios.post(requestUrl, request_data);
}
if (response.data.errcode === 0) {
return {
success: true,
message: 'ok',
};
} else {
return {
success: false,
message: response.data.errmsg,
};
}
} catch (e) {
console.error(e);
let msg = e.message;
if (msg.startsWith('access_token missing')) {
msg = '请求微信服务器失败,请检查配置是否正确或重试!';
}
return {
success: false,
message: msg,
};
}
}
module.exports = {
refreshToken,
requestToken,
pushWeChatCorpMessage,
};

View File

@@ -1,90 +0,0 @@
const axios = require('axios');
const { tokenStore, updateTokenStore } = require('./token');
const config = require('../config');
async function refreshToken() {
for (let [key, value] of tokenStore) {
if (value.wechatAppId) {
value.wechatToken = await requestToken(
value.wechatAppId,
value.wechatAppSecret
);
tokenStore.set(key, value);
}
}
console.log('Token refreshed.');
}
async function requestToken(appId, appSecret) {
let token = '';
try {
let res = await axios.get(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`
);
// console.debug(res);
if (res && res.data) {
if (res.data.access_token) {
token = res.data.access_token;
} else {
console.error(res.data);
}
}
} catch (e) {
console.error(e);
}
return token;
}
async function pushWeChatMessage(userPrefix, message) {
// Reference: https://mp.weixin.qq.com/debug/cgi-bin/readtmpl?t=tmplmsg/faq_tmpl
let user = tokenStore.get(userPrefix);
if (!user) {
return {
success: false,
message: `不存在的前缀:${userPrefix},请注意大小写`,
};
}
let access_token = user.wechatToken;
let request_data = {
touser: user.wechatOpenId,
template_id: user.wechatTemplateId,
};
if (message.content) {
request_data.url = `${config.href}message/${message.id}`;
}
request_data.data = { text: { value: message.description } };
let requestUrl = `https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=${access_token}`;
try {
let response = await axios.post(requestUrl, request_data);
if (response && response.data && response.data.errcode !== 0) {
// Failed to push message, get a new token and try again.
let token = await requestToken(user.wechatAppId, user.wechatAppSecret);
updateTokenStore(userPrefix, 'wechatToken', token);
requestUrl = `https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=${access_token}`;
response = await axios.post(requestUrl, request_data);
}
if (response.data.errcode === 0) {
return {
success: true,
message: 'ok',
};
} else {
return {
success: false,
message: response.data.errmsg,
};
}
} catch (e) {
console.error(e);
return {
success: false,
message: e.message,
};
}
}
module.exports = {
refreshToken,
requestToken,
pushWeChatMessage,
};

View File

@@ -1,10 +0,0 @@
const config = {
allowRegister: false,
port: process.env.PORT || 3000,
database: 'data.db',
href: process.env.HREF || 'https://your.domain.com/',
session_secret: 'change this',
cookie_secret: 'change this',
};
module.exports = config;

131
controller/file.go Normal file
View File

@@ -0,0 +1,131 @@
package controller
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"message-pusher/common"
"message-pusher/model"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type FileDeleteRequest struct {
Id int
Link string
//Token string
}
func UploadFile(c *gin.Context) {
uploadPath := common.UploadPath
//saveToDatabase := true
//path := c.PostForm("path")
//if path != "" { // Upload to explorer's path
// uploadPath = filepath.Join(common.ExplorerRootPath, path)
// if !strings.HasPrefix(uploadPath, common.ExplorerRootPath) {
// // In this case the given path is not valid, so we reset it to ExplorerRootPath.
// uploadPath = common.ExplorerRootPath
// }
// saveToDatabase = false
//}
description := c.PostForm("description")
if description == "" {
description = "无描述信息"
}
uploader := c.GetString("username")
if uploader == "" {
uploader = "匿名用户"
}
currentTime := time.Now().Format("2006-01-02 15:04:05")
form, err := c.MultipartForm()
if err != nil {
c.String(http.StatusBadRequest, fmt.Sprintf("get form err: %s", err.Error()))
return
}
files := form.File["file"]
for _, file := range files {
// In case someone wants to upload to other folders.
filename := filepath.Base(file.Filename)
link := filename
savePath := filepath.Join(uploadPath, filename)
if _, err := os.Stat(savePath); err == nil {
// File already existed.
t := time.Now()
timestamp := t.Format("_2006-01-02_15-04-05")
ext := filepath.Ext(filename)
if ext == "" {
link += timestamp
} else {
link = filename[:len(filename)-len(ext)] + timestamp + ext
}
savePath = filepath.Join(uploadPath, link)
}
if err := c.SaveUploadedFile(file, savePath); err != nil {
c.String(http.StatusBadRequest, fmt.Sprintf("upload file err: %s", err.Error()))
return
}
// save to database
fileObj := &model.File{
Description: description,
Uploader: uploader,
Time: currentTime,
Link: link,
Filename: filename,
}
err = fileObj.Insert()
if err != nil {
_ = fmt.Errorf(err.Error())
}
}
c.Redirect(http.StatusSeeOther, "./")
}
func DeleteFile(c *gin.Context) {
var deleteRequest FileDeleteRequest
err := json.NewDecoder(c.Request.Body).Decode(&deleteRequest)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
fileObj := &model.File{
Id: deleteRequest.Id,
}
model.DB.Where("id = ?", deleteRequest.Id).First(&fileObj)
err = fileObj.Delete()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": err.Error(),
})
} else {
message := "文件删除成功"
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
})
}
}
func DownloadFile(c *gin.Context) {
path := c.Param("file")
fullPath := filepath.Join(common.UploadPath, path)
if !strings.HasPrefix(fullPath, common.UploadPath) {
// We may being attacked!
c.Status(403)
return
}
c.File(fullPath)
// Update download counter
go func() {
model.UpdateDownloadCounter(path)
}()
}

179
controller/github.go Normal file
View File

@@ -0,0 +1,179 @@
package controller
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"message-pusher/common"
"message-pusher/model"
"net/http"
"strconv"
"time"
)
type GitHubOAuthResponse struct {
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
type GitHubUser struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
}
func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
if code == "" {
return nil, errors.New("无效的参数")
}
values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code}
jsonData, err := json.Marshal(values)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := http.Client{
Timeout: 5 * time.Second,
}
res, err := client.Do(req)
if err != nil {
common.SysLog(err.Error())
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
}
defer res.Body.Close()
var oAuthResponse GitHubOAuthResponse
err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
if err != nil {
return nil, err
}
req, err = http.NewRequest("GET", "https://api.github.com/user", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
res2, err := client.Do(req)
if err != nil {
common.SysLog(err.Error())
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
}
defer res2.Body.Close()
var githubUser GitHubUser
err = json.NewDecoder(res2.Body).Decode(&githubUser)
if err != nil {
return nil, err
}
if githubUser.Login == "" {
return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
}
return &githubUser, nil
}
func GitHubOAuth(c *gin.Context) {
session := sessions.Default(c)
username := session.Get("username")
if username != nil {
GitHubBind(c)
return
}
if !common.GitHubOAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未开启通过 GitHub 登录以及注册",
})
return
}
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
user := model.User{
GitHubId: githubUser.Login,
}
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
user.FillUserByGitHubId()
} else {
user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
user.DisplayName = githubUser.Name
user.Email = githubUser.Email
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
if err := user.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
}
if user.Status != common.UserStatusEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "用户已被封禁",
"success": false,
})
return
}
setupLogin(&user, c)
}
func GitHubBind(c *gin.Context) {
if !common.GitHubOAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未开启通过 GitHub 登录以及注册",
})
return
}
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
user := model.User{
GitHubId: githubUser.Login,
}
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该 GitHub 账户已被绑定",
})
return
}
id := c.GetInt("id")
user.Id = id
user.FillUserById()
user.GitHubId = githubUser.Login
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "bind",
})
return
}

167
controller/misc.go Normal file
View File

@@ -0,0 +1,167 @@
package controller
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"message-pusher/common"
"message-pusher/model"
"net/http"
)
func GetStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress,
},
})
return
}
func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["Notice"],
})
return
}
func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": common.OptionMap["About"],
})
return
}
func SendEmailVerification(c *gin.Context) {
email := c.Query("email")
if err := common.Validate.Var(email, "required,email"); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if model.IsEmailAlreadyTaken(email) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "邮箱地址已被占用",
})
return
}
code := common.GenerateVerificationCode(6)
common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+
"<p>您的验证码为: <strong>%s</strong></p>"+
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func SendPasswordResetEmail(c *gin.Context) {
email := c.Query("email")
if err := common.Validate.Var(email, "required,email"); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if !model.IsEmailAlreadyTaken(email) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该邮箱地址未注册",
})
return
}
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击<a href='%s'>此处</a>进行密码重置。</p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
type PasswordResetRequest struct {
Email string `json:"email"`
Token string `json:"token"`
}
func ResetPassword(c *gin.Context) {
var req PasswordResetRequest
err := json.NewDecoder(c.Request.Body).Decode(&req)
if req.Email == "" || req.Token == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "重置链接非法或已过期",
})
return
}
password := common.GenerateVerificationCode(12)
err = model.ResetUserPasswordByEmail(req.Email, password)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
common.DeleteKey(req.Email, common.PasswordResetPurpose)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": password,
})
return
}

69
controller/option.go Normal file
View File

@@ -0,0 +1,69 @@
package controller
import (
"encoding/json"
"github.com/gin-gonic/gin"
"message-pusher/common"
"message-pusher/model"
"net/http"
"strings"
)
func GetOptions(c *gin.Context) {
var options []*model.Option
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") {
continue
}
options = append(options, &model.Option{
Key: k,
Value: common.Interface2String(v),
})
}
common.OptionMapRWMutex.Unlock()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": options,
})
return
}
func UpdateOption(c *gin.Context) {
var option model.Option
err := json.NewDecoder(c.Request.Body).Decode(&option)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if option.Key == "GitHubOAuthEnabled" && option.Value == "true" && common.GitHubClientId == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 GitHub OAuth请先填入 GitHub Client ID 以及 GitHub Client Secret",
})
return
} else if option.Key == "WeChatAuthEnabled" && option.Value == "true" && common.WeChatServerAddress == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用微信登录,请先填入微信登录相关配置信息!",
})
return
}
err = model.UpdateOption(option.Key, option.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}

583
controller/user.go Normal file
View File

@@ -0,0 +1,583 @@
package controller
import (
"encoding/json"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"message-pusher/common"
"message-pusher/model"
"net/http"
"strconv"
"strings"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func Login(c *gin.Context) {
if !common.PasswordLoginEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "管理员关闭了密码登录",
"success": false,
})
return
}
var loginRequest LoginRequest
err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": "无效的参数",
"success": false,
})
return
}
username := loginRequest.Username
password := loginRequest.Password
if username == "" || password == "" {
c.JSON(http.StatusOK, gin.H{
"message": "无效的参数",
"success": false,
})
return
}
user := model.User{
Username: username,
Password: password,
}
err = user.ValidateAndFill()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
setupLogin(&user, c)
}
// setup session & cookies and then return user info
func setupLogin(user *model.User, c *gin.Context) {
session := sessions.Default(c)
session.Set("id", user.Id)
session.Set("username", user.Username)
session.Set("role", user.Role)
session.Set("status", user.Status)
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": "无法保存会话信息,请重试",
"success": false,
})
return
}
user.Password = ""
user.Token = ""
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
"data": user,
})
}
func Logout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
})
}
func Register(c *gin.Context) {
if !common.PasswordRegisterEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "管理员关闭了新用户注册",
"success": false,
})
return
}
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if err := common.Validate.Struct(&user); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "输入不合法 " + err.Error(),
})
return
}
if common.EmailVerificationEnabled {
if user.Email == "" || user.VerificationCode == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员开启了邮箱验证,请输入邮箱地址和验证码",
})
return
}
if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "验证码错误!",
})
return
}
}
cleanUser := model.User{
Username: user.Username,
Password: user.Password,
DisplayName: user.Username,
}
if common.EmailVerificationEnabled {
cleanUser.Email = user.Email
}
if err := cleanUser.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func GetAllUsers(c *gin.Context) {
users, err := model.GetAllUsers()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": users,
})
return
}
func SearchUsers(c *gin.Context) {
keyword := c.Query("keyword")
users, err := model.SearchUsers(keyword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": users,
})
return
}
func GetUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
user, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
myRole := c.GetInt("role")
if myRole <= user.Role {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权获取同级或更高等级用户的信息",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user,
})
return
}
func GenerateToken(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
user.Token = uuid.New().String()
user.Token = strings.Replace(user.Token, "-", "", -1)
if model.DB.Where("token = ?", user.Token).First(user).RowsAffected != 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请重试,系统生成的 UUID 竟然重复了!",
})
return
}
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user.Token,
})
return
}
func GetSelf(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user,
})
return
}
func UpdateUser(c *gin.Context) {
var updatedUser model.User
err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
if err != nil || updatedUser.Id == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if err := common.Validate.Struct(&updatedUser); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "输入不合法 " + err.Error(),
})
return
}
originUser, err := model.GetUserById(updatedUser.Id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
myRole := c.GetInt("role")
if myRole <= originUser.Role {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权更新同权限等级或更高权限等级的用户信息",
})
return
}
if myRole <= updatedUser.Role {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权将其他用户权限等级提升到大于等于自己的权限等级",
})
return
}
updatePassword := updatedUser.Password != ""
if err := updatedUser.Update(updatePassword); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func UpdateSelf(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if err := common.Validate.Struct(&user); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "输入不合法 " + err.Error(),
})
return
}
cleanUser := model.User{
Id: c.GetInt("id"),
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
}
updatePassword := user.Password != ""
if err := cleanUser.Update(updatePassword); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func DeleteUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
originUser, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
myRole := c.GetInt("role")
if myRole <= originUser.Role {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权删除同权限等级或更高权限等级的用户",
})
return
}
err = model.DeleteUserById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
}
func DeleteSelf(c *gin.Context) {
id := c.GetInt("id")
err := model.DeleteUserById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
// CreateUser Only admin user can call this, so we can trust it
func CreateUser(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
if err != nil || user.Username == "" || user.Password == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if user.DisplayName == "" {
user.DisplayName = user.Username
}
myRole := c.GetInt("role")
if user.Role >= myRole {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法创建权限大于等于自己的用户",
})
return
}
if err := user.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
type ManageRequest struct {
Username string `json:"username"`
Action string `json:"action"`
}
// ManageUser Only admin user can do this
func ManageUser(c *gin.Context) {
var req ManageRequest
err := json.NewDecoder(c.Request.Body).Decode(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
user := model.User{
Username: req.Username,
}
// Fill attributes
model.DB.Where(&user).First(&user)
if user.Id == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户不存在",
})
return
}
myRole := c.GetInt("role")
if myRole <= user.Role {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权更新同权限等级或更高权限等级的用户信息",
})
return
}
switch req.Action {
case "disable":
user.Status = common.UserStatusDisabled
case "enable":
user.Status = common.UserStatusEnabled
case "delete":
if err := user.Delete(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
case "promote":
if myRole != common.RoleRootUser {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "普通管理员用户无法提升其他用户为管理员",
})
return
}
user.Role = common.RoleAdminUser
case "demote":
user.Role = common.RoleCommonUser
}
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func EmailBind(c *gin.Context) {
email := c.Query("email")
code := c.Query("code")
if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "验证码错误!",
})
return
}
id := c.GetInt("id")
user := model.User{
Id: id,
}
user.FillUserById()
user.Email = email
// no need to check if this email already taken, because we have used verification code to check it
err := user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}

142
controller/wechat.go Normal file
View File

@@ -0,0 +1,142 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"message-pusher/common"
"message-pusher/model"
"net/http"
"strconv"
"time"
)
type wechatLoginResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data string `json:"data"`
}
func getWeChatIdByCode(code string) (string, error) {
if code == "" {
return "", errors.New("无效的参数")
}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", common.WeChatServerToken)
client := http.Client{
Timeout: 5 * time.Second,
}
httpResponse, err := client.Do(req)
if err != nil {
return "", err
}
defer httpResponse.Body.Close()
var res wechatLoginResponse
err = json.NewDecoder(httpResponse.Body).Decode(&res)
if err != nil {
return "", err
}
if !res.Success {
return "", errors.New(res.Message)
}
if res.Data == "" {
return "", errors.New("验证码错误或已过期")
}
return res.Data, nil
}
func WeChatAuth(c *gin.Context) {
if !common.WeChatAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "管理员未开启通过微信登录以及注册",
"success": false,
})
return
}
code := c.Query("code")
wechatId, err := getWeChatIdByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
user := model.User{
WeChatId: wechatId,
}
if model.IsWeChatIdAlreadyTaken(wechatId) {
user.FillUserByWeChatId()
} else {
user.Username = "wechat_" + strconv.Itoa(model.GetMaxUserId()+1)
user.DisplayName = "WeChat User"
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
if err := user.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
}
if user.Status != common.UserStatusEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "用户已被封禁",
"success": false,
})
return
}
setupLogin(&user, c)
}
func WeChatBind(c *gin.Context) {
if !common.WeChatAuthEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "管理员未开启通过微信登录以及注册",
"success": false,
})
return
}
code := c.Query("code")
wechatId, err := getWeChatIdByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
if model.IsWeChatIdAlreadyTaken(wechatId) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该微信账号已被绑定",
})
return
}
id := c.GetInt("id")
user := model.User{
Id: id,
}
user.FillUserById()
user.WeChatId = wechatId
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}

50
go.mod Normal file
View File

@@ -0,0 +1,50 @@
module message-pusher
// +heroku goVersion go1.18
go 1.18
require (
github.com/gin-contrib/sessions v0.0.5
github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19
github.com/gin-gonic/gin v1.8.1
github.com/go-playground/validator/v10 v10.11.1
github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.3.0
golang.org/x/crypto v0.1.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/mysql v1.4.3
gorm.io/driver/sqlite v1.4.3
gorm.io/gorm v1.24.0
)
require (
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/goccy/go-json v0.9.7 // indirect
github.com/gomodule/redigo v2.0.0+incompatible // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
github.com/stretchr/testify v1.8.0 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
golang.org/x/net v0.1.0 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/text v0.4.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

143
go.sum Normal file
View File

@@ -0,0 +1,143 @@
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 h1:J2LPEOcQmWaooBnBtUDV9KHFEnP5LYTZY03GiQ0oQBw=
github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg=
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k=
gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=

84
main.go Normal file
View File

@@ -0,0 +1,84 @@
package main
import (
"embed"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
"log"
"message-pusher/common"
"message-pusher/middleware"
"message-pusher/model"
"message-pusher/router"
"os"
"strconv"
)
//go:embed web/build
var buildFS embed.FS
//go:embed web/build/index.html
var indexPage []byte
func main() {
common.SetupGinLog()
common.SysLog("system started")
if os.Getenv("GIN_MODE") != "debug" {
gin.SetMode(gin.ReleaseMode)
}
// Initialize SQL Database
err := model.InitDB()
if err != nil {
common.FatalLog(err)
}
defer func() {
err := model.CloseDB()
if err != nil {
common.FatalLog(err)
}
}()
// Initialize Redis
err = common.InitRedisClient()
if err != nil {
common.FatalLog(err)
}
// Initialize options
model.InitOptionMap()
// Initialize HTTP server
server := gin.Default()
server.Use(middleware.CORS())
// Initialize session store
if common.RedisEnabled {
opt := common.ParseRedisOption()
store, _ := redis.NewStore(opt.MinIdleConns, opt.Network, opt.Addr, opt.Password, []byte(common.SessionSecret))
server.Use(sessions.Sessions("session", store))
} else {
store := cookie.NewStore([]byte(common.SessionSecret))
server.Use(sessions.Sessions("session", store))
}
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
port = strconv.Itoa(*common.Port)
}
//if *common.Host == "localhost" {
// ip := common.GetIp()
// if ip != "" {
// *common.Host = ip
// }
//}
//serverUrl := "http://" + *common.Host + ":" + port + "/"
//if !*common.NoBrowser {
// common.OpenBrowser(serverUrl)
//}
err = server.Run(":" + port)
if err != nil {
log.Println(err)
}
}

117
middleware/auth.go Normal file
View File

@@ -0,0 +1,117 @@
package middleware
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"message-pusher/common"
"message-pusher/model"
"net/http"
)
func authHelper(c *gin.Context, minRole int) {
session := sessions.Default(c)
username := session.Get("username")
role := session.Get("role")
id := session.Get("id")
status := session.Get("status")
authByToken := false
if username == nil {
// Check token
token := c.Request.Header.Get("Authorization")
if token == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,未登录或 token 无效",
})
c.Abort()
return
}
user := model.ValidateUserToken(token)
if user != nil && user.Username != "" {
// Token is valid
username = user.Username
role = user.Role
id = user.Id
status = user.Status
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,未登录或 token 无效",
})
c.Abort()
return
}
authByToken = true
}
if status.(int) == common.UserStatusDisabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已被封禁",
})
c.Abort()
return
}
if role.(int) < minRole {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无权进行此操作,未登录或 token 无效,或没有权限",
})
c.Abort()
return
}
c.Set("username", username)
c.Set("role", role)
c.Set("id", id)
c.Set("authByToken", authByToken)
c.Next()
}
func UserAuth() func(c *gin.Context) {
return func(c *gin.Context) {
authHelper(c, common.RoleCommonUser)
}
}
func AdminAuth() func(c *gin.Context) {
return func(c *gin.Context) {
authHelper(c, common.RoleAdminUser)
}
}
func RootAuth() func(c *gin.Context) {
return func(c *gin.Context) {
authHelper(c, common.RoleRootUser)
}
}
// NoTokenAuth You should always use this after normal auth middlewares.
func NoTokenAuth() func(c *gin.Context) {
return func(c *gin.Context) {
authByToken := c.GetBool("authByToken")
if authByToken {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "本接口不支持使用 token 进行验证",
})
c.Abort()
return
}
c.Next()
}
}
// TokenOnlyAuth You should always use this after normal auth middlewares.
func TokenOnlyAuth() func(c *gin.Context) {
return func(c *gin.Context) {
authByToken := c.GetBool("authByToken")
if !authByToken {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "本接口仅支持使用 token 进行验证",
})
c.Abort()
return
}
c.Next()
}
}

20
middleware/cors.go Normal file
View File

@@ -0,0 +1,20 @@
package middleware
import (
"github.com/gin-gonic/contrib/cors"
"github.com/gin-gonic/gin"
"time"
)
func CORS() gin.HandlerFunc {
config := cors.DefaultConfig()
config.AllowedHeaders = []string{"Authorization", "Content-Type", "Origin",
"Connection", "Accept-Encoding", "Accept-Language", "Host"}
config.AllowedMethods = []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"}
config.AllowCredentials = true
config.MaxAge = 12 * time.Hour
// if you want to allow all origins, comment the following two lines
config.AllowAllOrigins = false
config.AllowedOrigins = []string{"https://message-pusher.vercel.app"}
return cors.New(config)
}

103
middleware/rate-limit.go Normal file
View File

@@ -0,0 +1,103 @@
package middleware
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
"message-pusher/common"
"net/http"
"time"
)
var timeFormat = "2006-01-02T15:04:05.000Z"
var inMemoryRateLimiter common.InMemoryRateLimiter
func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
ctx := context.Background()
rdb := common.RDB
key := "rateLimit:" + mark + c.ClientIP()
listLength, err := rdb.LLen(ctx, key).Result()
if err != nil {
fmt.Println(err.Error())
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
if listLength < int64(maxRequestNum) {
rdb.LPush(ctx, key, time.Now().Format(timeFormat))
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
} else {
oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()
oldTime, err := time.Parse(timeFormat, oldTimeStr)
if err != nil {
fmt.Println(err)
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
nowTimeStr := time.Now().Format(timeFormat)
nowTime, err := time.Parse(timeFormat, nowTimeStr)
if err != nil {
fmt.Println(err)
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
// time.Since will return negative number!
// See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows
if int64(nowTime.Sub(oldTime).Seconds()) < duration {
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
c.Status(http.StatusTooManyRequests)
c.Abort()
return
} else {
rdb.LPush(ctx, key, time.Now().Format(timeFormat))
rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
}
}
}
func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
key := mark + c.ClientIP()
if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) {
c.Status(http.StatusTooManyRequests)
c.Abort()
return
}
}
func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {
if common.RedisEnabled {
return func(c *gin.Context) {
redisRateLimiter(c, maxRequestNum, duration, mark)
}
} else {
// It's safe to call multi times.
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
return func(c *gin.Context) {
memoryRateLimiter(c, maxRequestNum, duration, mark)
}
}
}
func GlobalWebRateLimit() func(c *gin.Context) {
return rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW")
}
func GlobalAPIRateLimit() func(c *gin.Context) {
return rateLimitFactory(common.GlobalApiRateLimitNum, common.GlobalApiRateLimitDuration, "GA")
}
func CriticalRateLimit() func(c *gin.Context) {
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
}
func DownloadRateLimit() func(c *gin.Context) {
return rateLimitFactory(common.DownloadRateLimitNum, common.DownloadRateLimitDuration, "DW")
}
func UploadRateLimit() func(c *gin.Context) {
return rateLimitFactory(common.UploadRateLimitNum, common.UploadRateLimitDuration, "UP")
}

View File

@@ -1,32 +0,0 @@
const config = require('../config');
exports.userRequired = (req, res, next) => {
if (req.session.user) {
if (req.session.user.isBlocked) {
req.flash('message', '用户账户被禁用,请联系管理员');
return res.redirect('/');
}
} else {
req.flash('message', '用户尚未登录,请登录');
return res.redirect('/login');
}
next();
};
exports.adminRequired = (req, res, next) => {
if (!req.session.user || !req.session.user.isAdmin) {
req.flash('message', '需要管理员权限');
return res.redirect('/login');
}
next();
};
exports.allowRegister = (req, res, next) => {
if (!config.allowRegister) {
if (!req.session.user || !req.session.user.isAdmin) {
req.flash('message', '管理员未开放注册!');
return res.redirect('/');
}
}
next();
};

53
model/file.go Normal file
View File

@@ -0,0 +1,53 @@
package model
import (
_ "gorm.io/driver/sqlite"
"gorm.io/gorm"
"message-pusher/common"
"os"
"path"
"strings"
)
type File struct {
Id int `json:"id"`
Filename string `json:"filename"`
Description string `json:"description"`
Uploader string `json:"uploader"`
Link string `json:"link" gorm:"unique"`
Time string `json:"time"`
DownloadCounter int `json:"download_counter"`
}
func GetAllFiles() ([]*File, error) {
var files []*File
var err error
err = DB.Find(&files).Error
return files, err
}
func QueryFiles(query string, startIdx int) ([]*File, error) {
var files []*File
var err error
query = strings.ToLower(query)
err = DB.Limit(common.ItemsPerPage).Offset(startIdx).Where("filename LIKE ? or description LIKE ? or uploader LIKE ? or time LIKE ?", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%").Order("id desc").Find(&files).Error
return files, err
}
func (file *File) Insert() error {
var err error
err = DB.Create(file).Error
return err
}
// Delete Make sure link is valid! Because we will use os.Remove to delete it!
func (file *File) Delete() error {
var err error
err = DB.Delete(file).Error
err = os.Remove(path.Join(common.UploadPath, file.Link))
return err
}
func UpdateDownloadCounter(link string) {
DB.Model(&File{}).Where("link = ?", link).UpdateColumn("download_counter", gorm.Expr("download_counter + 1"))
}

81
model/main.go Normal file
View File

@@ -0,0 +1,81 @@
package model
import (
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"message-pusher/common"
"os"
)
var DB *gorm.DB
func createRootAccountIfNeed() error {
var user User
//if user.Status != common.UserStatusEnabled {
if err := DB.First(&user).Error; err != nil {
hashedPassword, err := common.Password2Hash("123456")
if err != nil {
return err
}
rootUser := User{
Username: "root",
Password: hashedPassword,
Role: common.RoleRootUser,
Status: common.UserStatusEnabled,
DisplayName: "Root User",
}
DB.Create(&rootUser)
}
return nil
}
func CountTable(tableName string) (num int64) {
DB.Table(tableName).Count(&num)
return
}
func InitDB() (err error) {
var db *gorm.DB
if os.Getenv("SQL_DSN") != "" {
// Use MySQL
db, err = gorm.Open(mysql.Open(os.Getenv("SQL_DSN")), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
} else {
// Use SQLite
db, err = gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
PrepareStmt: true, // precompile SQL
})
common.SysLog("SQL_DSN not set, using SQLite as database")
}
if err == nil {
DB = db
err := db.AutoMigrate(&File{})
if err != nil {
return err
}
err = db.AutoMigrate(&User{})
if err != nil {
return err
}
err = db.AutoMigrate(&Option{})
if err != nil {
return err
}
err = createRootAccountIfNeed()
return err
} else {
common.FatalLog(err)
}
return err
}
func CloseDB() error {
sqlDB, err := DB.DB()
if err != nil {
return err
}
err = sqlDB.Close()
return err
}

123
model/option.go Normal file
View File

@@ -0,0 +1,123 @@
package model
import (
"errors"
"message-pusher/common"
"strconv"
"strings"
)
type Option struct {
Key string `json:"key" gorm:"primaryKey"`
Value string `json:"value"`
}
func AllOption() ([]*Option, error) {
var options []*Option
var err error
err = DB.Find(&options).Error
return options, err
}
func InitOptionMap() {
common.OptionMapRWMutex.Lock()
common.OptionMap = make(map[string]string)
common.OptionMap["FileUploadPermission"] = strconv.Itoa(common.FileUploadPermission)
common.OptionMap["FileDownloadPermission"] = strconv.Itoa(common.FileDownloadPermission)
common.OptionMap["ImageUploadPermission"] = strconv.Itoa(common.ImageUploadPermission)
common.OptionMap["ImageDownloadPermission"] = strconv.Itoa(common.ImageDownloadPermission)
common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled)
common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
common.OptionMap["SMTPServer"] = ""
common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = ""
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["Footer"] = common.Footer
common.OptionMap["ServerAddress"] = ""
common.OptionMap["GitHubClientId"] = ""
common.OptionMap["GitHubClientSecret"] = ""
common.OptionMap["WeChatServerAddress"] = ""
common.OptionMap["WeChatServerToken"] = ""
common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
common.OptionMapRWMutex.Unlock()
options, _ := AllOption()
for _, option := range options {
updateOptionMap(option.Key, option.Value)
}
}
func UpdateOption(key string, value string) error {
if key == "StatEnabled" && value == "true" && !common.RedisEnabled {
return errors.New("未启用 Redis无法启用统计功能")
}
// Save to database first
option := Option{
Key: key,
Value: value,
}
// When updating with struct it will only update non-zero fields by default
// So we have to use Select here
if DB.Model(&option).Where("key = ?", key).Update("value", option.Value).RowsAffected == 0 {
DB.Create(&option)
}
// Update OptionMap
updateOptionMap(key, value)
return nil
}
func updateOptionMap(key string, value string) {
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
common.OptionMap[key] = value
if strings.HasSuffix(key, "Permission") {
intValue, _ := strconv.Atoi(value)
switch key {
case "FileUploadPermission":
common.FileUploadPermission = intValue
case "FileDownloadPermission":
common.FileDownloadPermission = intValue
case "ImageUploadPermission":
common.ImageUploadPermission = intValue
case "ImageDownloadPermission":
common.ImageDownloadPermission = intValue
}
}
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
common.PasswordRegisterEnabled = boolValue
case "PasswordLoginEnabled":
common.PasswordLoginEnabled = boolValue
case "EmailVerificationEnabled":
common.EmailVerificationEnabled = boolValue
case "GitHubOAuthEnabled":
common.GitHubOAuthEnabled = boolValue
case "SMTPServer":
common.SMTPServer = value
case "SMTPAccount":
common.SMTPAccount = value
case "SMTPToken":
common.SMTPToken = value
case "ServerAddress":
common.ServerAddress = value
case "GitHubClientId":
common.GitHubClientId = value
case "GitHubClientSecret":
common.GitHubClientSecret = value
case "Footer":
common.Footer = value
case "WeChatServerAddress":
common.WeChatServerAddress = value
case "WeChatServerToken":
common.WeChatServerToken = value
case "WeChatAccountQRCodeImageURL":
common.WeChatAccountQRCodeImageURL = value
case "WeChatAuthEnabled":
common.WeChatAuthEnabled = boolValue
}
}

155
model/user.go Normal file
View File

@@ -0,0 +1,155 @@
package model
import (
"errors"
"message-pusher/common"
"strings"
)
type User struct {
Id int `json:"id"`
Username string `json:"username" gorm:"unique;index" validate:"max=12"`
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
Token string `json:"token;" gorm:"index"`
Email string `json:"email" gorm:"index" validate:"max=50"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
VerificationCode string `json:"verification_code" gorm:"-:all"`
}
func GetMaxUserId() int {
var user User
DB.Last(&user)
return user.Id
}
func GetAllUsers() (users []*User, err error) {
err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email"}).Find(&users).Error
return users, err
}
func SearchUsers(keyword string) (users []*User, err error) {
err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email"}).Where("id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?", keyword, keyword+"%", keyword+"%", keyword+"%").Find(&users).Error
return users, err
}
func GetUserById(id int, selectAll bool) (*User, error) {
user := User{Id: id}
var err error = nil
if selectAll {
err = DB.First(&user, "id = ?", id).Error
} else {
err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email", "wechat_id", "github_id"}).First(&user, "id = ?", id).Error
}
return &user, err
}
func DeleteUserById(id int) (err error) {
user := User{Id: id}
err = DB.Delete(&user).Error
return err
}
func (user *User) Insert() error {
var err error
if user.Password != "" {
user.Password, err = common.Password2Hash(user.Password)
if err != nil {
return err
}
}
err = DB.Create(user).Error
return err
}
func (user *User) Update(updatePassword bool) error {
var err error
if updatePassword {
user.Password, err = common.Password2Hash(user.Password)
if err != nil {
return err
}
}
err = DB.Model(user).Updates(user).Error
return err
}
func (user *User) Delete() error {
var err error
err = DB.Delete(user).Error
return err
}
// ValidateAndFill check password & user status
func (user *User) ValidateAndFill() (err error) {
// When querying with struct, GORM will only query with non-zero fields,
// that means if your fields value is 0, '', false or other zero values,
// it wont be used to build query conditions
password := user.Password
DB.Where(User{Username: user.Username}).First(user)
okay := common.ValidatePasswordAndHash(password, user.Password)
if !okay || user.Status != common.UserStatusEnabled {
return errors.New("用户名或密码错误,或者该用户已被封禁")
}
return nil
}
func (user *User) FillUserById() {
DB.Where(User{Id: user.Id}).First(user)
}
func (user *User) FillUserByEmail() {
DB.Where(User{Email: user.Email}).First(user)
}
func (user *User) FillUserByGitHubId() {
DB.Where(User{GitHubId: user.GitHubId}).First(user)
}
func (user *User) FillUserByWeChatId() {
DB.Where(User{WeChatId: user.WeChatId}).First(user)
}
func (user *User) FillUserByUsername() {
DB.Where(User{Username: user.Username}).First(user)
}
func ValidateUserToken(token string) (user *User) {
if token == "" {
return nil
}
token = strings.Replace(token, "Bearer ", "", 1)
user = &User{}
if DB.Where("token = ?", token).First(user).RowsAffected == 1 {
return user
}
return nil
}
func IsEmailAlreadyTaken(email string) bool {
return DB.Where("email = ?", email).Find(&User{}).RowsAffected == 1
}
func IsWeChatIdAlreadyTaken(wechatId string) bool {
return DB.Where("wechat_id = ?", wechatId).Find(&User{}).RowsAffected == 1
}
func IsGitHubIdAlreadyTaken(githubId string) bool {
return DB.Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1
}
func IsUsernameAlreadyTaken(username string) bool {
return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1
}
func ResetUserPasswordByEmail(email string, password string) error {
hashedPassword, err := common.Password2Hash(password)
if err != nil {
return err
}
err = DB.Model(&User{}).Where("email = ?", email).Update("password", hashedPassword).Error
return err
}

View File

@@ -1,24 +0,0 @@
const User = require('./user');
const Message = require('./message');
const sequelize = require('../common/database');
Message.belongsTo(User);
(async () => {
await sequelize.sync();
console.log('Database initialized.');
const isNoAdminExisted =
(await User.findOne({ where: { isAdmin: true } })) === null;
if (isNoAdminExisted) {
console.log('No admin user existed! Creating one for you.');
await User.create({
username: 'admin',
password: '123456',
isAdmin: true,
prefix: 'admin',
});
}
})();
exports.User = User;
exports.Message = Message;

View File

@@ -1,24 +0,0 @@
const { DataTypes, Model } = require('sequelize');
const sequelize = require('../common/database');
class Message extends Model {}
Message.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
title: DataTypes.STRING,
description: DataTypes.TEXT,
content: DataTypes.TEXT,
type: {
type: DataTypes.STRING,
defaultValue: 'test',
},
},
{ sequelize }
);
module.exports = Message;

View File

@@ -1,66 +0,0 @@
const { DataTypes, Model } = require('sequelize');
const sequelize = require('../common/database');
class User extends Model {}
User.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
username: {
type: DataTypes.STRING,
unique: true,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
accessToken: {
type: DataTypes.STRING,
defaultValue: '',
},
email: {
type: DataTypes.STRING,
},
prefix: {
type: DataTypes.STRING,
unique: true,
},
isAdmin: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
isBlocked: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
defaultMethod:{
type: DataTypes.STRING,
defaultValue: 'test'
},
// WeChat public account
wechatAppId: DataTypes.STRING,
wechatAppSecret: DataTypes.STRING,
wechatTemplateId: DataTypes.STRING,
wechatOpenId: DataTypes.STRING,
wechatVerifyToken: DataTypes.STRING,
// Email
smtpServer: {
type: DataTypes.STRING,
defaultValue: 'smtp.qq.com',
},
smtpUser: DataTypes.STRING,
smtpPass: DataTypes.STRING,
// WeChat corporation
corpId: DataTypes.STRING,
corpAgentId: DataTypes.STRING,
corpAppSecret: DataTypes.STRING,
corpUserId: DataTypes.STRING
},
{ sequelize }
);
module.exports = User;

View File

@@ -1,15 +0,0 @@
server {
server_name 你的域名;
location / {
proxy_pass http://localhost:3000; # 注意如果你改变了默认端口,记得在这里进行更新
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}

View File

@@ -1,35 +0,0 @@
{
"name": "message-pusher",
"version": "0.2.3",
"private": true,
"scripts": {
"start": "node ./app.js",
"devStart": "nodemon ./app.js"
},
"dependencies": {
"axios": "^0.21.1",
"compression": "^1.7.4",
"connect-flash": "^0.1.1",
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"dotenv": "^16.0.1",
"ejs": "^3.1.5",
"express": "~4.16.1",
"express-rate-limit": "^5.2.3",
"express-session": "^1.17.1",
"marked": ">=2.0.0",
"morgan": "~1.9.1",
"nodemailer": "^6.4.17",
"sequelize": "^6.3.5",
"serve-static": "^1.14.1",
"sqlite3": "^5.0.1",
"ws": "^7.4.6"
},
"devDependencies": {
"nodemon": "^2.0.6",
"prettier": "^2.1.1"
},
"prettier": {
"singleQuote": true
}
}

File diff suppressed because one or more lines are too long

View File

View File

@@ -1,246 +0,0 @@
body {
font-family: Verdana, Candara, Arial, Helvetica, Microsoft YaHei, sans-serif;
line-height: 1.6;
margin: 0;
}
nav {
margin-bottom: 16px;
}
a {
text-decoration: none;
}
.wrapper {
max-width: 960px;
margin: 0 auto;
}
#page-container {
position: relative;
min-height: 97vh;
}
#content-wrap {
padding-bottom: 4rem;
}
#footer {
height: 4rem;
}
#footer a {
color: black;
}
code {
font-family: Consolas, 'Courier New', monospace;
}
.page-card-list {
margin: 8px 8px;
}
.page-card-title {
font-size: x-large;
font-weight: 500;
color: #000000;
text-decoration: none;
}
.page-card-text {
margin-top: 8px;
}
.pagination {
margin: 16px 4px;
}
.pagination a {
border: none;
overflow: hidden;
}
.shadow {
box-shadow: 0 0.5em 1em -0.125em rgba(10,10,10,.1), 0 0 0 1px rgba(10,10,10,.02);
}
.nav-shadow {
box-shadow: 0 2px 3px rgba(26,26,26,.1);
}
.box .article {
overflow-wrap: break-word;
font-size: larger;
word-break: break-word;
line-height: 1.6;
padding: 16px;
margin-bottom: 16px;
background-color: #ffffff;
}
img {
max-width: 100%;
max-height: 100%;
}
.normal-container {
margin: auto;
max-width: 960px;
padding: 16px 16px;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.6;
font-size: larger;
}
.narrow-container {
margin: auto;
max-width: 560px;
padding: 16px 16px;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.6;
font-size: larger;
}
.article a {
color: #368CCB;
text-decoration: none;
}
.article a:hover {
color: #368CCB;
text-decoration: none;
}
.article h2,
.article h3,
.article h4,
.article h5,
.article h6 {
font-weight: 700;
line-height: 1.5;
margin: 20px 0 15px;
margin-block-start: 1em;
margin-block-end: 0.2em;
}
.article h1 {
font-size: 1.7em
}
.article h2 {
font-size: 1.6em
}
.article h3 {
font-size: 1.45em
}
.article h4 {
font-size: 1.25em;
}
.article h5 {
font-size: 1.1em;
}
.article h6 {
font-size: 1em;
font-weight: bold
}
@media screen and (max-width: 960px) {
.article h1 {
font-size: 1.5em
}
.article h2 {
font-size: 1.35em
}
.article h3 {
font-size: 1.3em
}
.article h4 {
font-size: 1.2em;
}
}
.article p {
margin-top: 0;
margin-bottom: 1.25rem;
}
.article table {
margin: auto;
border-collapse: collapse;
border-spacing: 0;
vertical-align: middle;
text-align: left;
min-width: 66%;
}
.article table td,
.article table th {
padding: 5px 8px;
border: 1px solid #bbb;
}
.article blockquote {
margin-left: 0;
padding: 0 1em;
font-size: smaller;
border-left: 5px solid #ddd;
}
.article pre {
overflow-x: auto;
padding: 0;
font-size: 16px;
margin-top: 12px;
margin-bottom: 12px;
}
.article ol {
text-decoration: none;
padding-inline-start: 40px;
margin-bottom: 1.25rem;
}
.article code {
color: #bc9458;
padding: .065em .4em;
}
.article .copyright{
display: none;
}
.info {
font-size: 14px;
line-height: 28px;
text-align: left;
color: #738292;
margin-bottom: 3em
}
.info a {
text-decoration: none;
color: inherit;
}
.links {
margin: 16px;
}
span.line {
display: inline-block;
}
.toc {
position: sticky;
top: 24px;
}

View File

View File

@@ -1,2 +0,0 @@
User-agent: *
Disallow: /

64
router/api-router.go Normal file
View File

@@ -0,0 +1,64 @@
package router
import (
"github.com/gin-gonic/gin"
"message-pusher/controller"
"message-pusher/middleware"
)
func SetApiRouter(router *gin.Engine) {
apiRouter := router.Group("/api")
apiRouter.Use(middleware.GlobalAPIRateLimit())
{
apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/notice", controller.GetNotice)
apiRouter.GET("/about", controller.GetAbout)
apiRouter.GET("/verification", middleware.CriticalRateLimit(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login)
userRoute.GET("/logout", controller.Logout)
selfRoute := userRoute.Group("/")
selfRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth())
{
selfRoute.GET("/self", controller.GetSelf)
selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateToken)
}
adminRoute := userRoute.Group("/")
adminRoute.Use(middleware.AdminAuth(), middleware.NoTokenAuth())
{
adminRoute.GET("/", controller.GetAllUsers)
adminRoute.GET("/search", controller.SearchUsers)
adminRoute.GET("/:id", controller.GetUser)
adminRoute.POST("/", controller.CreateUser)
adminRoute.POST("/manage", controller.ManageUser)
adminRoute.PUT("/", controller.UpdateUser)
adminRoute.DELETE("/:id", controller.DeleteUser)
}
}
optionRoute := apiRouter.Group("/option")
optionRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
}
fileRoute := apiRouter.Group("/file")
{
fileRoute.GET("/:id", middleware.DownloadRateLimit(), controller.DownloadFile)
fileRoute.POST("/", middleware.UserAuth(), middleware.UploadRateLimit(), controller.UploadFile)
fileRoute.DELETE("/:id", middleware.UserAuth(), controller.DeleteFile)
}
}
}

11
router/main.go Normal file
View File

@@ -0,0 +1,11 @@
package router
import (
"embed"
"github.com/gin-gonic/gin"
)
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
SetApiRouter(router)
setWebRouter(router, buildFS, indexPage)
}

18
router/web-router.go Normal file
View File

@@ -0,0 +1,18 @@
package router
import (
"embed"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
"message-pusher/common"
"message-pusher/middleware"
"net/http"
)
func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
router.Use(middleware.GlobalWebRateLimit())
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
router.NoRoute(func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
})
}

View File

@@ -1,209 +0,0 @@
const express = require('express');
const router = express.Router();
const { User } = require('../models');
const { tokenStore } = require('../common/token');
const requestWeChatToken = require('../common/wechat').requestToken;
const requestCorpToken = require('../common/wechat-corp').requestToken;
const {
userRequired,
adminRequired,
allowRegister,
} = require('../middlewares/web_auth');
const config = require('../config');
router.get('/', (req, res, next) => {
let showGuidance = false;
if (
req.session.user &&
!req.session.user.wechatAppId &&
!req.session.user.corpId &&
!req.session.user.smtpUser
) {
showGuidance = true;
}
if (process.env.MODE === '1') {
showGuidance = false;
}
res.render('index', {
message: req.flash('message'),
showGuidance,
});
});
router.get('/login', (req, res, next) => {
res.render('login', {
message: req.flash('message'),
});
});
router.post('/login', async (req, res, next) => {
if (process.env.MODE === '1') {
return res.render('register', {
message: '当前运行模式为 Heroku 模式,该模式下禁止用户登录',
isErrorMessage: true,
});
}
let user = {
username: req.body.username,
password: req.body.password,
};
let message = '';
res.locals.isErrorMessage = true;
try {
user = await User.findOne({ where: user });
if (user) {
req.session.user = user;
req.flash(
'message',
`欢迎${user.isAdmin ? '管理员' : '普通'}用户 ${
user.username
} 登录系统!`
);
return res.redirect('/');
} else {
message = '用户名或密码错误';
}
} catch (e) {
console.error(e);
message = e.message;
}
res.render('login', {
message,
});
});
router.get('/logout', userRequired, (req, res, next) => {
req.session.user = undefined;
req.flash('message', '已退出登录');
res.redirect('/');
});
router.get('/register', allowRegister, (req, res, next) => {
res.render('register');
});
router.post('/register', allowRegister, async (req, res, next) => {
if (process.env.MODE === '1') {
return res.render('register', {
message: '当前运行模式为 Heroku 模式,该模式下禁止用户注册',
isErrorMessage: true,
});
}
let user = {
username: req.body.username,
password: req.body.password,
};
let message = '';
try {
user = await User.create(user);
message = '用户创建成功,请登录';
req.flash('message', message);
return res.redirect('/login');
} catch (e) {
console.error(e);
message = '用户名已被占用';
}
res.render('register', { message, isErrorMessage: true });
});
router.get('/configure', userRequired, (req, res, next) => {
let showPasswordWarning = false;
if (req.session.user && req.session.user.password === '123456') {
showPasswordWarning = true;
}
res.locals.message = req.flash('message');
res.locals.showPasswordWarning = showPasswordWarning;
if (req.session.user.prefix === null) {
req.session.user.prefix = req.session.user.username;
}
res.locals.verifyUrl = config.href + req.session.user.prefix + '/verify';
res.render('configure', req.session.user);
});
router.post('/configure', userRequired, async (req, res, next) => {
let id = req.session.user.id;
let user = {
// Common
username: req.body.username,
password: req.body.password,
accessToken: req.body.accessToken,
defaultMethod: req.body.defaultMethod,
prefix: req.body.prefix,
// WeChat public account
wechatAppId: req.body.wechatAppId,
wechatAppSecret: req.body.wechatAppSecret,
wechatTemplateId: req.body.wechatTemplateId,
wechatOpenId: req.body.wechatOpenId,
wechatVerifyToken: req.body.wechatVerifyToken,
// Email
email: req.body.email,
smtpServer: req.body.smtpServer,
smtpUser: req.body.smtpUser,
smtpPass: req.body.smtpPass,
// WeChat corp
corpId: req.body.corpId,
corpAgentId: req.body.corpAgentId,
corpAppSecret: req.body.corpAppSecret,
corpUserId: req.body.corpUserId,
};
for (let field in user) {
let value = user[field];
value = value.trim();
if (value || field === 'accessToken') {
user[field] = value;
} else {
delete user[field];
}
}
let message = '';
try {
let userObj = await User.findOne({
where: {
id: id,
},
});
if (userObj) {
await userObj.update(user);
}
if (userObj.prefix !== req.session.user.prefix) {
tokenStore.delete(req.session.user.prefix);
}
req.session.user = userObj;
tokenStore.set(userObj.prefix, {
// Common
accessToken: userObj.accessToken,
defaultMethod: userObj.defaultMethod,
// WeChat test account
wechatAppId: userObj.wechatAppId,
wechatAppSecret: userObj.wechatAppSecret,
wechatTemplateId: userObj.wechatTemplateId,
wechatOpenId: userObj.wechatOpenId,
wechatVerifyToken: userObj.wechatVerifyToken,
wechatToken: await requestWeChatToken(
userObj.wechatAppId,
userObj.wechatAppSecret
),
// Email
email: userObj.email,
smtpServer: userObj.smtpServer,
smtpUser: userObj.smtpUser,
smtpPass: userObj.smtpPass,
// WeChat corporation account
corpId: userObj.corpId,
corpAgentId: userObj.corpAgentId,
corpAppSecret: userObj.corpAppSecret,
corpUserId: userObj.corpUserId,
corpToken: await requestCorpToken(userObj.corpId, userObj.corpAppSecret),
});
message = '配置更新成功';
console.debug(tokenStore);
} catch (e) {
console.error(e);
message = e.message;
}
req.flash('message', message);
res.redirect('/configure');
});
module.exports = router;

View File

@@ -1,34 +0,0 @@
const express = require('express');
const { Message } = require('../models');
const { md2html } = require('../common/utils');
const router = express.Router();
router.get('/delete/:id', (req, res, next) => {
// TODO: delete message
res.json({
success: true,
message: 'Ok',
});
});
router.get('/:id', async (req, res, next) => {
const id = req.params.id;
try {
let message = await Message.findOne({
where: {
id: id,
},
});
if (message) {
message.content = md2html(message.content);
res.render('article', {
message,
});
}
} catch (e) {
res.status(404);
}
});
module.exports = router;

View File

@@ -1,48 +0,0 @@
const express = require('express');
const crypto = require('crypto');
const Message = require('../models/message').Message;
const { processMessage } = require('../common/message');
const { tokenStore } = require('../common/token');
const router = express.Router();
router.all('/:userPrefix/verify', (req, res, next) => {
// 验证消息来自微信服务器https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
const userPrefix = req.params.userPrefix;
const { signature, timestamp, nonce, echostr } = req.query;
const token = tokenStore.get(userPrefix).wechatVerifyToken;
let tmp_array = [token, timestamp, nonce].sort();
let tmp_string = tmp_array.join('');
tmp_string = crypto.createHash('sha1').update(tmp_string).digest('hex');
if (tmp_string === signature) {
res.send(echostr);
} else {
res.send('verification failed');
}
});
router.all('/:userPrefix/:description', async (req, res, next) => {
const userPrefix = req.params.userPrefix;
let message = {
title: '消息推送',
description: req.params.description,
token: req.query.token,
};
res.json(await processMessage(userPrefix, message));
});
router.all('/:userPrefix', async (req, res, next) => {
const userPrefix = req.params.userPrefix;
let message = {
type: req.query.type || req.body.type,
title: req.query.title || req.body.title || '无标题',
description: req.query.description || req.body.description || '无描述',
content: req.query.content || req.body.content,
email: req.query.email || req.body.email,
token: req.query.token || req.body.token,
};
let result = await processMessage(userPrefix, message);
res.json(result);
});
module.exports = router;

View File

@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title><%= message.title %></title>
<meta name="theme-color" content="#ffffff"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<link rel="stylesheet" href="/public/main.css">
</head>
<body>
<div class="container" style="max-width: 960px">
<div style="margin: 16px 16px 16px 24px">
<h1 class="title"><%= message.title %></h1>
<h2 class="subtitle"><%- message.description %></h2>
<article id="article">
<%- message.content %>
</article>
</div>
</div>
</body>
</html>

View File

@@ -1,279 +0,0 @@
<%- include('./partials/header') %>
<script>
// Credit: https://codepen.io/t7team/pen/ZowdRN
function openTab(e, tabName) {
let i, x, tabLinks;
x = document.getElementsByClassName('content-tab');
for (i = 0; i < x.length; i++) {
x[i].style.display = 'none';
}
tabLinks = document.getElementsByClassName('tab');
for (i = 0; i < x.length; i++) {
tabLinks[i].className = tabLinks[i].className.replace(' is-active', '');
}
document.getElementById(tabName).style.display = 'block';
e.className += ' is-active';
}
</script>
<div class="normal-container">
<div>
<h2 class="title">配置页面</h2>
<%- include('./partials/message') %>
<% if(showPasswordWarning) { %>
<article class="message is-warning">
<div class="message-header">
<p>警告</p>
</div>
<div class="message-body">
你正在使用的密码是默认密码,请尽快修改!
</div>
</article>
<% } %>
<article class="message is-light">
<div class="message-header">
<p>注意</p>
</div>
<div class="message-body">
如果遇到问题,请仔细阅读
<a target="_blank"
href="https://github.com/songquanpeng/message-pusher/blob/master/README.md">README</a>,如果还不能解决,请提
<a target="_blank"
href="https://github.com/songquanpeng/message-pusher/issues/new">issue</a>。
<br />
<br />
如果本项目对你有意义,<a target="_blank" href="https://github.com/songquanpeng/message-pusher">请 ⭐ 该项目</a>
<del>这是我继续维护的主要动力</del>
,谢谢 :)。
</div>
</article>
<form action="/configure" method="post">
<div class="tabs">
<ul>
<li class="tab is-active" onclick="openTab(this,'userTab')"><a>用户设置</a></li>
<li class="tab" onclick="openTab(this,'testTab')"><a>微信测试号设置</a></li>
<li class="tab" onclick="openTab(this,'corpTab')"><a>微信企业号设置</a></li>
<li class="tab" onclick="openTab(this,'emailTab')"><a>邮件推送设置</a></li>
</ul>
</div>
<div id="userTab" class="content-tab">
<div class="field">
<label class="label">用户名</label>
<div class="control">
<input class="input" name="username" value="<%- username; %>" type="text"
placeholder="请输入新的用户名">
</div>
</div>
<div class="field">
<label class="label">密码</label>
<div class="control">
<input class="input" name="password" type="text" placeholder="请输入新的密码">
</div>
</div>
<div class="field">
<label class="label">默认推送方式</label>
<article class="message">
<div class="message-body">
可选值有test使用微信测试号进行推送corp使用微信企业号进行推送email使用邮件进行推送client使用客户端进行推送
</div>
</article>
<div class="control">
<input class="input" name="defaultMethod" value="<%- defaultMethod; %>" type="text"
placeholder="请输入新的默认推送方式">
</div>
</div>
<div class="field">
<label class="label">前缀</label>
<article class="message">
<div class="message-body">
前缀默认和用户名相同,如非必要,无需修改
</div>
</article>
<div class="control">
<input class="input" name="prefix" value="<%- prefix ? prefix : username; %>" type="text"
placeholder="请输入你的前缀">
</div>
</div>
<div class="field">
<label class="label">ACCESS TOKEN</label>
<article class="message">
<div class="message-body">
Access Token 可防止未授权者利用本系统向你发送消息,留空则不做该检查
</div>
</article>
<div class="control">
<input class="input" name="accessToken" value="<%- accessToken; %>" type="text"
placeholder="请设置新的 ACCESS TOKEN可不填">
</div>
</div>
</div>
<div id="testTab" class="content-tab" style="display:none">
<article class="message">
<div class="message-body">
<a target="_blank"
href="https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index">微信公众平台测试号配置链接</a>
<br>
<br>
根据你在 config.js 中填写的 href 以及你的 prefix你的接口验证 URL为<%- verifyUrl; %>。
<br>
</div>
</article>
<div class="field">
<label class="label">APP ID</label>
<div class="control">
<input class="input" name="wechatAppId" value="<%- wechatAppId; %>" type="text"
placeholder="请输入 APP ID">
</div>
</div>
<div class="field">
<label class="label">APP SECRET</label>
<div class="control">
<input class="input" name="wechatAppSecret" value="<%- wechatAppSecret; %>" type="text"
placeholder="请输入 APP SECRET">
</div>
</div>
<div class="field">
<label class="label">OPEN ID</label>
<div class="control">
<input class="input" name="wechatOpenId" value="<%- wechatOpenId; %>" type="text"
placeholder="请输入你的 OPEN ID">
</div>
</div>
<div class="field">
<label class="label">TEMPLATE ID</label>
<div class="control">
<input class="input" name="wechatTemplateId" value="<%- wechatTemplateId; %>" type="text"
placeholder="请输入一个模板通知 ID">
</div>
</div>
<div class="field">
<label class="label">TOKEN</label>
<div class="control">
<input class="input" name="wechatVerifyToken" value="<%- wechatVerifyToken; %>" type="text"
placeholder="请输入你的 TOKEN">
</div>
</div>
</div>
<div id="corpTab" class="content-tab" style="display:none">
<article class="message">
<div class="message-body">
<a target="_blank" href="https://work.weixin.qq.com/">在此处注册微信企业号</a>,之后<a target="_blank"
href="https://work.weixin.qq.com/wework_admin/frame#profile/wxPlugin">在此处扫码关注</a>
再之后<a target="_blank"
href="https://work.weixin.qq.com/wework_admin/frame#apps">在此处创建一个应用</a>。
<br>
</div>
</article>
<div class="field">
<label class="label">企业 ID</label>
<article class="message">
<div class="message-body">
<a target="_blank"
href="https://work.weixin.qq.com/wework_admin/frame#profile">在此处找到企业 ID</a>
<br>
</div>
</article>
<div class="control">
<input class="input" name="corpId" value="<%- corpId; %>" type="text"
placeholder="请输入企业 ID">
</div>
</div>
<div class="field">
<label class="label">应用 AgentId</label>
<article class="message">
<div class="message-body">
应用详情页可找到
</div>
</article>
<div class="control">
<input class="input" name="corpAgentId" value="<%- corpAgentId; %>" type="text"
placeholder="请输入应用的 AgentId">
</div>
</div>
<div class="field">
<label class="label">应用 Secret</label>
<article class="message">
<div class="message-body">
应用详情页可找到
</div>
</article>
<div class="control">
<input class="input" name="corpAppSecret" value="<%- corpAppSecret; %>" type="text"
placeholder="请输入应用的 Secret">
</div>
</div>
<div class="field">
<label class="label">用户账号</label>
<article class="message">
<div class="message-body">
<a target="_blank"
href="https://work.weixin.qq.com/wework_admin/frame#contacts">在此处找到你的用户账号</a>
<br>
</div>
</article>
<div class="control">
<input class="input" name="corpUserId" value="<%- corpUserId; %>" type="text"
placeholder="请输入你的 corpUserId">
</div>
</div>
</div>
<div id="emailTab" class="content-tab" style="display:none">
<div class="field">
<label class="label">默认目标邮箱</label>
<article class="message">
<div class="message-body">
要用 QQ 邮箱或者 Foxmail 邮箱,否则将无法通过微信得到邮件消息提醒
</div>
</article>
<div class="control">
<input class="input" name="email" value="<%- email; %>" type="email" placeholder="请输入你的邮箱(可不填)">
</div>
</div>
<div class="field">
<label class="label">SMTP 服务器地址</label>
<div class="control">
<input class="input" name="smtpServer" value="<%- smtpServer; %>" type="text"
placeholder="如 smtp.qq.com">
</div>
</div>
<div class="field">
<label class="label">SMTP 账号</label>
<div class="control">
<input class="input" name="smtpUser" value="<%- smtpUser; %>" type="email"
placeholder="请输入 SMTP 账号(是一个邮箱地址)">
</div>
</div>
<div class="field">
<label class="label">SMTP 凭据</label>
<div class="control">
<input class="input" name="smtpPass" type="text" placeholder="请输入新的 SMTP Token">
</div>
</div>
</div>
<div class="field is-grouped is-grouped-right" style="margin-top: 16px">
<div class="control">
<input type="submit" class="button is-light" value="提交">
</div>
<div class="control">
<input type="reset" class="button is-light" value="重置">
</div>
</div>
</form>
</div>
</div>
<%- include('./partials/footer') %>

View File

@@ -1,10 +0,0 @@
</div>
<footer class="footer" style="background-color: white">
<div class="content has-text-centered" >
<p>
<strong><a href="https://github.com/songquanpeng/wechat-message-push">WeChat Message Push</a></strong> by <a href="https://github.com/songquanpeng">JustSong</a>. The source code is licensed
<a href="http://opensource.org/licenses/mit-license.php">MIT</a>.
</p>
</div>
</footer>
</body>

View File

@@ -1,27 +0,0 @@
<%- include('./partials/header') %>
<div class="normal-container">
<%- include('./partials/message') %>
<% if(showGuidance) { %>
<article class="message is-warning">
<div class="message-header">
<p>系统尚未完成配置</p>
</div>
<div class="message-body">
点击<a href="/configure">此处</a>访问配置页面,如需帮助,请点击上方帮助按钮。
</div>
</article>
<% }%>
<article class="message is-light">
<div class="message-header">
<p>系统状况</p>
</div>
<div class="message-body">
运行模式:<%= process.env.MODE === '1' ? "Heroku 模式" : "普通模式"%>
<br>
内存占用:<%= (process.memoryUsage().rss / (1024 *1024)).toFixed(2) %> MB
</div>
</article>
</div>
<%- include('./partials/footer') %>

View File

@@ -1,10 +0,0 @@
<%- include('./header') %>
<article class="message is-primary">
<div class="message-header">
<p>注意</p>
</div>
<div class="message-body">
<%= message %>
</div>
</article>
<%- include('./footer') %>

View File

@@ -1,35 +0,0 @@
<%- include('./partials/header') %>
<div class="narrow-container">
<div>
<h2 class="title">用户登录</h2>
<%- include('./partials/message') %>
<form action="/login" method="post">
<div class="field">
<label class="label">用户名</label>
<div class="control">
<input class="input" name="username" type="text" required placeholder="请输入用户名">
</div>
</div>
<div class="field">
<label class="label">密码</label>
<div class="control">
<input class="input" name="password" type="password" required placeholder="请输入你的密码">
</div>
</div>
<div class="field is-grouped is-grouped-right">
<div class="control">
<input type="submit" class="button is-light" value="提交">
</div>
<div class="control">
<input type="reset" class="button is-light" value="重置">
</div>
</div>
</form>
</div>
</div>
<%- include('./partials/footer') %>

View File

@@ -1,3 +0,0 @@
<%- include('./partials/header') %>
<%- include('./partials/message') %>
<%- include('./partials/footer') %>

View File

@@ -1,12 +0,0 @@
</div>
<footer class="footer" style="background-color: white">
<div class="content has-text-centered" >
<p>
<a href="https://github.com/songquanpeng/message-pusher">消息推送服务</a> 由 <a href="https://github.com/songquanpeng">JustSong</a> 构建,源代码遵循
<a href="http://opensource.org/licenses/mit-license.php">MIT</a> 协议
</p>
</div>
</footer>
</div>
</body>
</html>

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>消息推送服务</title>
<meta name="theme-color" content="#ffffff"/>
<link rel="stylesheet" href="/public/bulma.min.css">
<link rel="stylesheet" href="/public/main.css">
<script src="/public/main.js"></script>
</head>
<body>
<div>
<%- include('./nav') %>
<div class="container">

View File

@@ -1,12 +0,0 @@
<% if(message && message.length) { %>
<article id='message' class="message <%= isErrorMessage ? 'is-danger' : 'is-info'%>">
<div class="message-body">
<%= message %>
</div>
<script>
setTimeout(function (){
document.getElementById('message').style.display='none';
}, 5000)
</script>
</article>
<% }%>

View File

@@ -1,54 +0,0 @@
<nav class="navbar nav-shadow" role="navigation" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item is-size-5" href="/" style="font-weight: bold">
消息推送服务
</a>
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false"
data-target="mainNavbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="mainNavbar" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/"> 首页 </a>
<a class="navbar-item" target="_blank" href="https://github.com/songquanpeng/message-pusher/blob/master/README.md"> 帮助 </a>
<a class="navbar-item" target="_blank" href="https://iamazing.cn/page/message-pusher"> 关于 </a>
</div>
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<% if (isLogged) { %>
<% if (isAdmin) { %>
<a class="button is-light" href="/register">添加新用户</a>
<% } %>
<a class="button is-light" href="/configure">配置</a>
<a class="button is-light" href="/logout">退出</a>
<% } else { %>
<a class="button is-light" href="/register">注册</a>
<a class="button is-light" href="/login">登录</a>
<% } %>
</div>
</div>
</div>
</div>
</div>
</nav>
<script>
document.addEventListener('DOMContentLoaded', () => {
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
if ($navbarBurgers.length > 0) {
$navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
const target = el.dataset.target;
const $target = document.getElementById(target);
el.classList.toggle('is-active');
$target.classList.toggle('is-active');
});
});
}
});
</script>

View File

@@ -1,12 +0,0 @@
<h1 class="title is-3 is-4-mobile"><%-page.title%></h1>
<div class="info">
<span class="line">
Tag:
<% page.tag.trim().split(" ").forEach(function (tag) {if (tag !== "") {%>
<a class="tag is-light" href='/tag/<%= tag %>'><%= tag %></a>
<% }}); %>
</span>
<span class="line">Posted on <span class="tag is-light"><%-page.post_time%></span></span>
<span class="line">Edited on <span class="tag is-light"><%-page.edit_time%></span></span>
<span class="line">Views: <span class="tag is-light"><%-page.view%></span></span>
</div>

View File

@@ -1,35 +0,0 @@
<%- include('./partials/header') %>
<div class="narrow-container">
<div>
<h2 class="title">用户注册</h2>
<%- include('./partials/message') %>
<form action="/register" method="post">
<div class="field">
<label class="label">用户名</label>
<div class="control">
<input class="input" name="username" type="text" required placeholder="请输入用户名">
</div>
</div>
<div class="field">
<label class="label">密码</label>
<div class="control">
<input class="input" name="password" type="password" required placeholder="请输入你的密码">
</div>
</div>
<div class="field is-grouped is-grouped-right">
<div class="control">
<input type="submit" class="button is-light" value="提交">
</div>
<div class="control">
<input type="reset" class="button is-light" value="重置">
</div>
</div>
</form>
</div>
</div>
<%- include('./partials/footer') %>

26
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea
package-lock.json
yarn.lock

21
web/README.md Normal file
View File

@@ -0,0 +1,21 @@
# React Template
## Basic Usages
```shell
# Runs the app in the development mode
npm start
# Builds the app for production to the `build` folder
npm run build
```
If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
for example: `REACT_APP_SERVER=http://your.domain.com`.
Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
## Reference
1. https://github.com/OIerDb-ng/OIerDb
2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example

49
web/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "react-template",
"version": "0.1.0",
"private": true,
"dependencies": {
"axios": "^0.27.2",
"history": "^5.3.0",
"marked": "^4.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"react-toastify": "^9.0.8",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"prettier": "^2.7.1"
},
"prettier": {
"singleQuote": true,
"jsxSingleQuote": true
},
"proxy": "http://localhost:3000"
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

18
web/public/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<title>消息推送服务</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

3
web/public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

141
web/src/App.js Normal file
View File

@@ -0,0 +1,141 @@
import React, { lazy, Suspense, useEffect } from 'react';
import { Route, Routes } from 'react-router-dom';
import Loading from './components/Loading';
import User from './pages/User';
import { PrivateRoute } from './components/PrivateRoute';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';
import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
import AddUser from './pages/User/AddUser';
import { API, showError } from './helpers';
import PasswordResetForm from './components/PasswordResetForm';
import GitHubOAuth from './components/GitHubOAuth';
import PasswordResetConfirm from './components/PasswordResetConfirm';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
function App() {
const loadStatus = async () => {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success) {
localStorage.setItem('status', JSON.stringify(data));
localStorage.setItem('footer_html', data.footer_html);
} else {
showError('无法正常连接至服务器!');
}
};
useEffect(() => {
loadStatus().then();
}, []);
return (
<Routes>
<Route
path="/"
element={
<Suspense fallback={<Loading></Loading>}>
<Home />
</Suspense>
}
/>
<Route
path="/user"
element={
<PrivateRoute>
<User />
</PrivateRoute>
}
/>
<Route
path="/user/edit/:id"
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path="/user/edit"
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path="/user/add"
element={
<Suspense fallback={<Loading></Loading>}>
<AddUser />
</Suspense>
}
/>
<Route
path="/user/reset"
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetConfirm />
</Suspense>
}
/>
<Route
path="/login"
element={
<Suspense fallback={<Loading></Loading>}>
<LoginForm />
</Suspense>
}
/>
<Route
path="/register"
element={
<Suspense fallback={<Loading></Loading>}>
<RegisterForm />
</Suspense>
}
/>
<Route
path="/reset"
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetForm />
</Suspense>
}
/>
<Route
path="/oauth/github"
element={
<Suspense fallback={<Loading></Loading>}>
<GitHubOAuth />
</Suspense>
}
/>
<Route
path="/setting"
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>}>
<Setting />
</Suspense>
</PrivateRoute>
}
/>
<Route
path="/about"
element={
<Suspense fallback={<Loading></Loading>}>
<About />
</Suspense>
}
/>
<Route path="*" element={NotFound} />
</Routes>
);
}
export default App;

View File

@@ -0,0 +1,44 @@
import React, { useEffect, useState } from 'react';
import { Container, Segment } from 'semantic-ui-react';
const Footer = () => {
const [Footer, setFooter] = useState('');
useEffect(() => {
let savedFooter = localStorage.getItem('footer_html');
if (!savedFooter) savedFooter = '';
setFooter(savedFooter);
});
return (
<Segment vertical>
<Container textAlign="center">
{Footer === '' ? (
<div className="custom-footer">
<a
href="https://github.com/songquanpeng/message-pusher"
target="_blank"
>
消息推送服务 {process.env.REACT_APP_VERSION}{' '}
</a>
{' '}
<a href="https://github.com/songquanpeng" target="_blank">
JustSong
</a>{' '}
构建源代码遵循{' '}
<a href="https://opensource.org/licenses/mit-license.php">
MIT 协议
</a>
</div>
) : (
<div
className="custom-footer"
dangerouslySetInnerHTML={{ __html: Footer }}
></div>
)}
</Container>
</Segment>
);
};
export default Footer;

View File

@@ -0,0 +1,57 @@
import React, { useContext, useEffect, useState } from 'react';
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers';
import { UserContext } from '../context/User';
const GitHubOAuth = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState('处理中...');
const [processing, setProcessing] = useState(true);
let navigate = useNavigate();
const sendCode = async (code, count) => {
const res = await API.get(`/api/oauth/github?code=${code}`);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess('绑定成功!');
navigate('/setting');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
navigate('/');
}
} else {
showError(message);
if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`);
navigate('/setting'); // in case this is failed to bind GitHub
return;
}
count++;
setPrompt(`出现错误,第 ${count} 次重试中...`);
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, count);
}
};
useEffect(() => {
let code = searchParams.get('code');
sendCode(code, 0).then();
}, []);
return (
<Segment style={{ minHeight: '300px' }}>
<Dimmer active inverted>
<Loader size='large'>{prompt}</Loader>
</Dimmer>
</Segment>
);
};
export default GitHubOAuth;

View File

@@ -0,0 +1,183 @@
import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { Button, Container, Icon, Menu, Segment } from 'semantic-ui-react';
import { API, isAdmin, isMobile, showSuccess } from '../helpers';
import '../index.css';
// Header Buttons
const headerButtons = [
{
name: '首页',
to: '/',
icon: 'home',
},
{
name: '用户',
to: '/user',
icon: 'user',
admin: true,
},
{
name: '设置',
to: '/setting',
icon: 'setting',
},
{
name: '关于',
to: '/about',
icon: 'info circle',
},
];
const Header = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
let size = isMobile() ? 'large' : '';
const [showSidebar, setShowSidebar] = useState(false);
async function logout() {
setShowSidebar(false);
await API.get('/api/user/logout');
showSuccess('注销成功!');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
}
const toggleSidebar = () => {
setShowSidebar(!showSidebar);
};
const renderButtons = (isMobile) => {
return headerButtons.map((button) => {
if (button.admin && !isAdmin()) return <></>;
if (isMobile) {
return (
<Menu.Item
onClick={() => {
navigate(button.to);
setShowSidebar(false);
}}
>
{button.name}
</Menu.Item>
);
}
return (
<Menu.Item key={button.name} as={Link} to={button.to}>
<Icon name={button.icon} />
{button.name}
</Menu.Item>
);
});
};
if (isMobile()) {
return (
<>
<Menu
borderless
size={size}
style={
showSidebar
? {
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px',
}
: { borderTop: 'none', height: '52px' }
}
>
<Container>
<Menu.Item as={Link} to="/">
<img
src="/logo.png"
alt="logo"
style={{ marginRight: '0.75em' }}
/>
<div style={{ fontSize: '20px' }}>
<b>消息推送服务</b>
</div>
</Menu.Item>
<Menu.Menu position="right">
<Menu.Item onClick={toggleSidebar}>
<Icon name={showSidebar ? 'close' : 'sidebar'} />
</Menu.Item>
</Menu.Menu>
</Container>
</Menu>
{showSidebar ? (
<Segment style={{ marginTop: 0, borderTop: '0' }}>
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
{renderButtons(true)}
<Menu.Item>
{userState.user ? (
<Button onClick={logout}>注销</Button>
) : (
<>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/login');
}}
>
登录
</Button>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/register');
}}
>
注册
</Button>
</>
)}
</Menu.Item>
</Menu>
</Segment>
) : (
<></>
)}
</>
);
}
return (
<>
<Menu borderless size={size} style={{ borderTop: 'none' }}>
<Container>
<Menu.Item as={Link} to="/" className={'hide-on-mobile'}>
<img src="/logo.png" alt="logo" style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>消息推送服务</b>
</div>
</Menu.Item>
{renderButtons(false)}
<Menu.Menu position="right">
{userState.user ? (
<Menu.Item
name="注销"
onClick={logout}
className="btn btn-link"
/>
) : (
<Menu.Item
name="登录"
as={Link}
to="/login"
className="btn btn-link"
/>
)}
</Menu.Menu>
</Container>
</Menu>
</>
);
};
export default Header;

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Segment, Dimmer, Loader } from 'semantic-ui-react';
const Loading = ({ prompt: name = 'page' }) => {
return (
<Segment style={{ height: 100 }}>
<Dimmer active inverted>
<Loader indeterminate>加载{name}...</Loader>
</Dimmer>
</Segment>
);
};
export default Loading;

Some files were not shown because too many files have changed in this diff Show More