├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ ├── github-pages.yml
│ ├── macos-release.yml
│ ├── windows-release.yml
│ ├── docker-image-amd64.yml
│ ├── linux-release.yml
│ └── docker-image-arm64.yml
├── .gitignore
├── web
├── vercel.json
├── public
│ ├── logo.png
│ ├── robots.txt
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── helpers
│ │ ├── history.js
│ │ ├── index.js
│ │ ├── auth-header.js
│ │ ├── api.js
│ │ ├── render.js
│ │ └── loader.js
│ ├── constants
│ │ ├── common.constant.js
│ │ ├── index.js
│ │ ├── toast.constants.js
│ │ ├── user.constants.js
│ │ └── channel.constants.js
│ ├── pages
│ │ ├── User
│ │ │ ├── index.js
│ │ │ ├── AddUser.js
│ │ │ └── EditUser.js
│ │ ├── Channel
│ │ │ └── index.js
│ │ ├── Message
│ │ │ ├── index.js
│ │ │ └── EditMessage.js
│ │ ├── Webhook
│ │ │ ├── index.js
│ │ │ └── EditWebhook.js
│ │ ├── NotFound
│ │ │ └── index.js
│ │ ├── Setting
│ │ │ └── index.js
│ │ ├── About
│ │ │ └── index.js
│ │ └── Home
│ │ │ └── index.js
│ ├── components
│ │ ├── PrivateRoute.js
│ │ ├── Loading.js
│ │ ├── Footer.js
│ │ ├── GitHubOAuth.js
│ │ ├── PasswordResetConfirm.js
│ │ ├── PasswordResetForm.js
│ │ ├── PushSetting.js
│ │ ├── OtherSetting.js
│ │ └── Header.js
│ ├── context
│ │ ├── User
│ │ │ ├── reducer.js
│ │ │ └── index.js
│ │ └── Status
│ │ │ ├── reducer.js
│ │ │ └── index.js
│ ├── index.css
│ └── index.js
├── .gitignore
├── README.md
└── package.json
├── common
├── validate.go
├── template.go
├── crypto.go
├── embed-file-system.go
├── redis.go
├── public
│ └── message.html
├── logger.go
├── init.go
├── rate-limit.go
├── email.go
├── verification.go
├── constants.go
└── utils.go
├── router
├── main.go
├── web-router.go
└── api-router.go
├── middleware
├── cache.go
├── sse.go
├── auth.go
├── turnstile-check.go
└── rate-limit.go
├── docker-compose.yml
├── Dockerfile
├── channel
├── email.go
├── custom.go
├── bark.go
├── group.go
├── main.go
├── discord.go
├── corp.go
├── message-queue.go
├── one-bot.go
├── tencent-alarm.go
├── ding.go
├── telegram.go
├── lark.go
├── wechat-test-account.go
├── client.go
├── lark-app.go
└── wechat-corp-account.go
├── LICENSE
├── controller
├── message-sse.go
├── websocket.go
├── option.go
├── wechat.go
├── misc.go
├── github.go
└── channel.go
├── docs
└── API.md
├── main.go
├── model
├── main.go
├── webhook.go
├── message.go
├── channel.go
└── option.go
├── go.mod
└── bin
├── migrate_v3_to_v4.sql
└── migrate_v3_to_v4.py
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom: ['https://iamazing.cn/page/reward']
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .vscode
3 | upload
4 | *.exe
5 | *.db
6 | build
--------------------------------------------------------------------------------
/web/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/web/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/message-pusher/HEAD/web/public/logo.png
--------------------------------------------------------------------------------
/web/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/songquanpeng/message-pusher/HEAD/web/public/favicon.ico
--------------------------------------------------------------------------------
/web/src/helpers/history.js:
--------------------------------------------------------------------------------
1 | import { createBrowserHistory } from 'history';
2 |
3 | export const history = createBrowserHistory();
--------------------------------------------------------------------------------
/web/src/constants/common.constant.js:
--------------------------------------------------------------------------------
1 | export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
2 |
--------------------------------------------------------------------------------
/web/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | export * from './history';
2 | export * from './auth-header';
3 | export * from './utils';
4 | export * from './api';
--------------------------------------------------------------------------------
/web/src/constants/index.js:
--------------------------------------------------------------------------------
1 | export * from './toast.constants';
2 | export * from './user.constants';
3 | export * from './common.constant';
4 | export * from './channel.constants';
--------------------------------------------------------------------------------
/common/validate.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "github.com/go-playground/validator/v10"
4 |
5 | var Validate *validator.Validate
6 |
7 | func init() {
8 | Validate = validator.New()
9 | }
10 |
--------------------------------------------------------------------------------
/web/src/constants/toast.constants.js:
--------------------------------------------------------------------------------
1 | export const toastConstants = {
2 | SUCCESS_TIMEOUT: 500,
3 | INFO_TIMEOUT: 3000,
4 | ERROR_TIMEOUT: 5000,
5 | WARNING_TIMEOUT: 10000,
6 | NOTICE_TIMEOUT: 20000
7 | };
8 |
--------------------------------------------------------------------------------
/router/main.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-gonic/gin"
6 | )
7 |
8 | func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
9 | SetApiRouter(router)
10 | setWebRouter(router, buildFS, indexPage)
11 | }
12 |
--------------------------------------------------------------------------------
/middleware/cache.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | )
6 |
7 | func Cache() func(c *gin.Context) {
8 | return func(c *gin.Context) {
9 | c.Header("Cache-Control", "max-age=604800") // one week
10 | c.Next()
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | message-pusher:
5 | image: justsong/message-pusher
6 | restart: unless-stopped
7 | ports:
8 | - 3000:3000
9 | environment:
10 | - TZ=Asia/Shanghai
11 | volumes:
12 | - ./data:/data
13 | - /etc/localtime:/etc/localtime:ro
--------------------------------------------------------------------------------
/web/src/helpers/auth-header.js:
--------------------------------------------------------------------------------
1 | export function authHeader() {
2 | // return authorization header with jwt token
3 | let user = JSON.parse(localStorage.getItem('user'));
4 |
5 | if (user && user.token) {
6 | return { 'Authorization': 'Bearer ' + user.token };
7 | } else {
8 | return {};
9 | }
10 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 项目群聊
4 | url: https://msgpusher.com/
5 | about: 演示站首页有官方群聊信息
6 | - name: 赞赏支持
7 | url: https://iamazing.cn/page/reward
8 | about: 请作者喝杯咖啡,以激励作者持续开发
9 | - name: 付费部署或定制功能
10 | url: https://msgpusher.com/
11 | about: 加群后联系群主
12 |
--------------------------------------------------------------------------------
/web/src/helpers/api.js:
--------------------------------------------------------------------------------
1 | import { showError } from './utils';
2 | import axios from 'axios';
3 |
4 | export const API = axios.create({
5 | baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '',
6 | });
7 |
8 | API.interceptors.response.use(
9 | (response) => response,
10 | (error) => {
11 | showError(error);
12 | }
13 | );
14 |
--------------------------------------------------------------------------------
/web/src/pages/User/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 | import UsersTable from '../../components/UsersTable';
4 |
5 | const User = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default User;
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 功能请求
3 | about: 使用简练详细的语言描述希望加入的新功能
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **例行检查**
11 | + [ ] 我已确认目前没有类似 issue
12 | + [ ] 我已确认我已升级到最新版本
13 | + [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
14 | + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭
15 |
16 | **功能描述**
17 |
18 | **应用场景**
19 |
--------------------------------------------------------------------------------
/web/src/components/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import { Navigate } from 'react-router-dom';
2 |
3 | import { history } from '../helpers';
4 |
5 |
6 | function PrivateRoute({ children }) {
7 | if (!localStorage.getItem('user')) {
8 | return ;
9 | }
10 | return children;
11 | }
12 |
13 | export { PrivateRoute };
--------------------------------------------------------------------------------
/common/template.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "embed"
5 | "html/template"
6 | )
7 |
8 | //go:embed public
9 | var FS embed.FS
10 |
11 | func LoadTemplate() *template.Template {
12 | var funcMap = template.FuncMap{
13 | "unescape": UnescapeHTML,
14 | }
15 | t := template.Must(template.New("").Funcs(funcMap).ParseFS(FS, "public/*.html"))
16 | return t
17 | }
18 |
--------------------------------------------------------------------------------
/web/src/pages/Channel/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Header, Segment } from 'semantic-ui-react';
3 | import ChannelsTable from '../../components/ChannelsTable';
4 |
5 | const Channel = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default Channel;
15 |
--------------------------------------------------------------------------------
/web/src/pages/Message/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Header, Segment } from 'semantic-ui-react';
3 | import MessagesTable from '../../components/MessagesTable';
4 |
5 | const Message = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default Message;
15 |
--------------------------------------------------------------------------------
/web/src/pages/Webhook/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Header, Segment } from 'semantic-ui-react';
3 | import WebhooksTable from '../../components/WebhooksTable';
4 |
5 | const Webhook = () => (
6 | <>
7 |
8 |
9 |
10 |
11 | >
12 | );
13 |
14 | export default Webhook;
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 报告问题
3 | about: 使用简练详细的语言描述你遇到的问题
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **例行检查**
11 | + [ ] 我已确认目前没有类似 issue
12 | + [ ] 我已确认我已升级到最新版本
13 | + [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
14 | + [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭
15 |
16 | **问题描述**
17 |
18 | **复现步骤**
19 |
20 | **预期结果**
21 |
22 | **相关截图**
23 | 如果没有的话,请删除此节。
--------------------------------------------------------------------------------
/web/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Dimmer, Loader, Segment } from 'semantic-ui-react';
3 |
4 | const Loading = ({ prompt: name = 'page' }) => {
5 | return (
6 |
7 |
8 | 加载 {name} 中...
9 |
10 |
11 | );
12 | };
13 |
14 | export default Loading;
15 |
--------------------------------------------------------------------------------
/web/src/context/User/reducer.js:
--------------------------------------------------------------------------------
1 | export const reducer = (state, action) => {
2 | switch (action.type) {
3 | case 'login':
4 | return {
5 | ...state,
6 | user: action.payload
7 | };
8 | case 'logout':
9 | return {
10 | ...state,
11 | user: undefined
12 | };
13 |
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | export const initialState = {
20 | user: undefined
21 | };
--------------------------------------------------------------------------------
/web/src/context/Status/reducer.js:
--------------------------------------------------------------------------------
1 | export const reducer = (state, action) => {
2 | switch (action.type) {
3 | case 'set':
4 | return {
5 | ...state,
6 | status: action.payload,
7 | };
8 | case 'unset':
9 | return {
10 | ...state,
11 | status: undefined,
12 | };
13 | default:
14 | return state;
15 | }
16 | };
17 |
18 | export const initialState = {
19 | status: undefined,
20 | };
21 |
--------------------------------------------------------------------------------
/web/src/pages/NotFound/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Header } from 'semantic-ui-react';
3 |
4 | const NotFound = () => (
5 | <>
6 |
14 |
15 | 未找到所请求的页面
16 |
17 | >
18 | );
19 |
20 | export default NotFound;
21 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | .idea
25 | package-lock.json
26 | yarn.lock
--------------------------------------------------------------------------------
/middleware/sse.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import "github.com/gin-gonic/gin"
4 |
5 | func SetSSEHeaders() func(c *gin.Context) {
6 | return func(c *gin.Context) {
7 | c.Writer.Header().Set("Content-Type", "text/event-stream")
8 | c.Writer.Header().Set("Cache-Control", "no-cache")
9 | c.Writer.Header().Set("Connection", "keep-alive")
10 | c.Writer.Header().Set("Transfer-Encoding", "chunked")
11 | c.Writer.Header().Set("X-Accel-Buffering", "no")
12 | c.Next()
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/common/crypto.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import "golang.org/x/crypto/bcrypt"
4 |
5 | func Password2Hash(password string) (string, error) {
6 | passwordBytes := []byte(password)
7 | hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
8 | return string(hashedPassword), err
9 | }
10 |
11 | func ValidatePasswordAndHash(password string, hash string) bool {
12 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
13 | return err == nil
14 | }
15 |
--------------------------------------------------------------------------------
/web/src/context/User/index.js:
--------------------------------------------------------------------------------
1 | // contexts/User/index.jsx
2 |
3 | import React from "react"
4 | import { reducer, initialState } from "./reducer"
5 |
6 | export const UserContext = React.createContext({
7 | state: initialState,
8 | dispatch: () => null
9 | })
10 |
11 | export const UserProvider = ({ children }) => {
12 | const [state, dispatch] = React.useReducer(reducer, initialState)
13 |
14 | return (
15 |
16 | { children }
17 |
18 | )
19 | }
--------------------------------------------------------------------------------
/web/src/context/Status/index.js:
--------------------------------------------------------------------------------
1 | // contexts/User/index.jsx
2 |
3 | import React from 'react';
4 | import { initialState, reducer } from './reducer';
5 |
6 | export const StatusContext = React.createContext({
7 | state: initialState,
8 | dispatch: () => null,
9 | });
10 |
11 | export const StatusProvider = ({ children }) => {
12 | const [state, dispatch] = React.useReducer(reducer, initialState);
13 |
14 | return (
15 |
16 | {children}
17 |
18 | );
19 | };
--------------------------------------------------------------------------------
/web/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 | 消息推送服务
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/web/src/constants/user.constants.js:
--------------------------------------------------------------------------------
1 | export const userConstants = {
2 | REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
3 | REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
4 | REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
5 |
6 | LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
7 | LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
8 | LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
9 |
10 | LOGOUT: 'USERS_LOGOUT',
11 |
12 | GETALL_REQUEST: 'USERS_GETALL_REQUEST',
13 | GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
14 | GETALL_FAILURE: 'USERS_GETALL_FAILURE',
15 |
16 | DELETE_REQUEST: 'USERS_DELETE_REQUEST',
17 | DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
18 | DELETE_FAILURE: 'USERS_DELETE_FAILURE'
19 | };
20 |
--------------------------------------------------------------------------------
/common/embed-file-system.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-contrib/static"
6 | "io/fs"
7 | "net/http"
8 | )
9 |
10 | // Credit: https://github.com/gin-contrib/static/issues/19
11 |
12 | type embedFileSystem struct {
13 | http.FileSystem
14 | }
15 |
16 | func (e embedFileSystem) Exists(prefix string, path string) bool {
17 | _, err := e.Open(path)
18 | if err != nil {
19 | return false
20 | }
21 | return true
22 | }
23 |
24 | func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
25 | efs, err := fs.Sub(fsEmbed, targetPath)
26 | if err != nil {
27 | panic(err)
28 | }
29 | return embedFileSystem{
30 | FileSystem: http.FS(efs),
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/web/README.md:
--------------------------------------------------------------------------------
1 | # React Template
2 |
3 | ## Basic Usages
4 |
5 | ```shell
6 | # Install dependencies
7 | npm install
8 |
9 | # Runs the app in the development mode
10 | npm start
11 |
12 | # Builds the app for production to the `build` folder
13 | npm run build
14 | ```
15 |
16 | If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
17 | for example: `REACT_APP_SERVER=http://your.domain.com`.
18 |
19 | Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
20 |
21 | ## Reference
22 |
23 | 1. https://github.com/OIerDb-ng/OIerDb
24 | 2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
25 |
--------------------------------------------------------------------------------
/router/web-router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-contrib/static"
6 | "github.com/gin-gonic/gin"
7 | "message-pusher/common"
8 | "message-pusher/controller"
9 | "message-pusher/middleware"
10 | "net/http"
11 | )
12 |
13 | func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
14 | router.Use(middleware.GlobalWebRateLimit())
15 | router.Use(middleware.Cache())
16 | router.GET("/public/static/:file", controller.GetStaticFile)
17 | router.GET("/message/:link", controller.RenderMessage)
18 | router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
19 | router.NoRoute(func(c *gin.Context) {
20 | c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16 as builder
2 |
3 | WORKDIR /build
4 | COPY ./web .
5 | COPY ./VERSION .
6 | RUN yarn install
7 | RUN REACT_APP_VERSION=$(cat VERSION) yarn build
8 |
9 | FROM golang AS builder2
10 |
11 | ENV GO111MODULE=on \
12 | CGO_ENABLED=1 \
13 | GOOS=linux
14 |
15 | WORKDIR /build
16 | COPY . .
17 | COPY --from=builder /build/build ./web/build
18 | RUN go mod download
19 | RUN go build -ldflags "-s -w -X 'message-pusher/common.Version=$(cat VERSION)' -extldflags '-static'" -o message-pusher
20 |
21 | FROM alpine
22 |
23 | ENV PORT=3000
24 | RUN apk update \
25 | && apk upgrade \
26 | && apk add --no-cache ca-certificates tzdata \
27 | && update-ca-certificates 2>/dev/null || true
28 | COPY --from=builder2 /build/message-pusher /
29 | EXPOSE 3000
30 | WORKDIR /data
31 | ENTRYPOINT ["/message-pusher"]
32 |
--------------------------------------------------------------------------------
/web/src/helpers/render.js:
--------------------------------------------------------------------------------
1 | import { Label } from 'semantic-ui-react';
2 | import { timestamp2string } from './utils';
3 | import React from 'react';
4 | import { CHANNEL_OPTIONS } from '../constants';
5 |
6 | let channelMap = undefined;
7 |
8 | export function renderChannel(key) {
9 | if (channelMap === undefined) {
10 | channelMap = new Map();
11 | CHANNEL_OPTIONS.forEach((option) => {
12 | channelMap[option.key] = option;
13 | });
14 | }
15 | let channel = channelMap[key];
16 | if (channel) {
17 | return (
18 |
21 | );
22 | }
23 | return (
24 |
27 | );
28 | }
29 |
30 | export function renderTimestamp(timestamp) {
31 | return <>{timestamp2string(timestamp)}>;
32 | }
33 |
--------------------------------------------------------------------------------
/web/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding-top: 55px;
4 | overflow-y: scroll;
5 | font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | scrollbar-width: none;
9 | }
10 |
11 | body::-webkit-scrollbar {
12 | display: none;
13 | }
14 |
15 | code {
16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
17 | }
18 |
19 | .main-content {
20 | padding: 4px;
21 | }
22 |
23 | .small-icon .icon {
24 | font-size: 1em !important;
25 | }
26 |
27 | .custom-footer {
28 | font-size: 1.1em;
29 | }
30 |
31 | @media only screen and (max-width: 600px) {
32 | .hide-on-mobile {
33 | display: none !important;
34 | }
35 | }
36 |
37 | .quote {
38 | margin-left: 0;
39 | padding: 0 1em;
40 | border-left: 0.25em solid #ddd;
41 | }
--------------------------------------------------------------------------------
/common/redis.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "context"
5 | "github.com/go-redis/redis/v8"
6 | "os"
7 | "time"
8 | )
9 |
10 | var RDB *redis.Client
11 | var RedisEnabled = true
12 |
13 | // InitRedisClient This function is called after init()
14 | func InitRedisClient() (err error) {
15 | if os.Getenv("REDIS_CONN_STRING") == "" {
16 | RedisEnabled = false
17 | SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
18 | return nil
19 | }
20 | opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
21 | if err != nil {
22 | panic(err)
23 | }
24 | RDB = redis.NewClient(opt)
25 |
26 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
27 | defer cancel()
28 |
29 | _, err = RDB.Ping(ctx).Result()
30 | return err
31 | }
32 |
33 | func ParseRedisOption() *redis.Options {
34 | opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
35 | if err != nil {
36 | panic(err)
37 | }
38 | return opt
39 | }
40 |
--------------------------------------------------------------------------------
/web/src/helpers/loader.js:
--------------------------------------------------------------------------------
1 | import { API } from './api';
2 | import { showError } from './utils';
3 |
4 | export const loadUser = async () => {
5 | let res = await API.get(`/api/user/self`);
6 | const { success, message, data } = res.data;
7 | if (success) {
8 | if (data.channel === '') {
9 | data.channel = 'email';
10 | }
11 | if (data.token === ' ') {
12 | data.token = '';
13 | }
14 | return data;
15 | } else {
16 | showError(message);
17 | return null;
18 | }
19 | };
20 |
21 | export const loadUserChannels = async () => {
22 | let res = await API.get(`/api/channel?brief=true`);
23 | const { success, message, data } = res.data;
24 | if (success) {
25 | data.forEach((channel) => {
26 | channel.key = channel.name;
27 | channel.text = channel.name;
28 | channel.value = channel.name;
29 | if (channel.description === '') {
30 | channel.description = '无备注信息';
31 | }
32 | });
33 | return data;
34 | } else {
35 | showError(message);
36 | return null;
37 | }
38 | };
--------------------------------------------------------------------------------
/channel/email.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "message-pusher/common"
7 | "message-pusher/model"
8 | "strings"
9 | )
10 |
11 | func SendEmailMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
12 | if message.To != "" {
13 | if user.SendEmailToOthers != common.SendEmailToOthersAllowed && user.Role < common.RoleAdminUser {
14 | return errors.New("没有权限发送邮件给其他人,请联系管理员为你添加该权限")
15 | }
16 | user.Email = message.To
17 | }
18 | if user.Email == "" {
19 | return errors.New("未配置邮箱地址")
20 | }
21 | subject := message.Title
22 | content := message.Content
23 | if subject == common.SystemName || subject == "" {
24 | subject = message.Description
25 | } else {
26 | content = fmt.Sprintf("%s\n\n%s", message.Description, message.Content)
27 | }
28 | var err error
29 | message.HTMLContent, err = common.Markdown2HTML(content)
30 | if err != nil {
31 | common.SysLog(err.Error())
32 | }
33 | user.Email = strings.ReplaceAll(user.Email, "|", ";")
34 | return common.SendEmail(subject, user.Email, message.HTMLContent)
35 | }
36 |
--------------------------------------------------------------------------------
/web/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { Container } from 'semantic-ui-react';
5 | import App from './App';
6 | import Header from './components/Header';
7 | import Footer from './components/Footer';
8 | import 'semantic-ui-css/semantic.min.css';
9 | import './index.css';
10 | import { UserProvider } from './context/User';
11 | import { ToastContainer } from 'react-toastify';
12 | import 'react-toastify/dist/ReactToastify.css';
13 | import { StatusProvider } from './context/Status';
14 |
15 | const root = ReactDOM.createRoot(document.getElementById('root'));
16 | root.render(
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 |
--------------------------------------------------------------------------------
/common/public/message.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{.title}}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {{.title}}
18 |
19 | 发布于:{{.time}}
20 |
21 |
22 | {{.description | unescape}}
23 |
24 | {{.content | unescape}}
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 JustSong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/github-pages.yml:
--------------------------------------------------------------------------------
1 | name: Build GitHub Pages
2 | on:
3 | workflow_dispatch:
4 | inputs:
5 | name:
6 | description: 'Reason'
7 | required: false
8 | jobs:
9 | build-and-deploy:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout 🛎️
13 | 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.
14 | with:
15 | persist-credentials: false
16 | - 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.
17 | env:
18 | CI: ""
19 | run: |
20 | cd web
21 | npm install
22 | npm run build
23 |
24 | - name: Deploy 🚀
25 | uses: JamesIves/github-pages-deploy-action@releases/v3
26 | with:
27 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
28 | BRANCH: gh-pages # The branch the action should deploy to.
29 | FOLDER: web/build # The folder the action should deploy.
--------------------------------------------------------------------------------
/channel/custom.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "net/http"
7 | "os"
8 | "strings"
9 |
10 | "message-pusher/common"
11 | "message-pusher/model"
12 | )
13 |
14 | func SendCustomMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
15 | url := channel_.URL
16 | if strings.HasPrefix(url, "http:") && os.Getenv("CHANNEL_URL_ALLOW_NON_HTTPS") != "true" {
17 | return errors.New("自定义通道必须使用 HTTPS 协议")
18 | }
19 | if strings.HasPrefix(url, common.ServerAddress) {
20 | return errors.New("自定义通道不能使用本服务地址")
21 | }
22 | template := channel_.Other
23 | template = common.Replace(template, "$url", message.URL, -1)
24 | template = common.Replace(template, "$to", message.To, -1)
25 | template = common.Replace(template, "$title", message.Title, -1)
26 | template = common.Replace(template, "$description", message.Description, -1)
27 | template = common.Replace(template, "$content", message.Content, -1)
28 | reqBody := []byte(template)
29 | resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
30 | if err != nil {
31 | return err
32 | }
33 | if resp.StatusCode != 200 {
34 | return errors.New(resp.Status)
35 | }
36 | return nil
37 | }
38 |
--------------------------------------------------------------------------------
/channel/bark.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "message-pusher/model"
9 | "net/http"
10 | )
11 |
12 | type barkMessageRequest struct {
13 | Title string `json:"title"`
14 | Body string `json:"body"`
15 | URL string `json:"url"`
16 | }
17 |
18 | type barkMessageResponse struct {
19 | Code int `json:"code"`
20 | Message string `json:"message"`
21 | }
22 |
23 | func SendBarkMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
24 | url := fmt.Sprintf("%s/%s", channel_.URL, channel_.Secret)
25 | req := barkMessageRequest{
26 | Title: message.Title,
27 | Body: message.Content,
28 | URL: message.URL,
29 | }
30 | if message.Content == "" {
31 | req.Body = message.Description
32 | }
33 | reqBody, err := json.Marshal(req)
34 | if err != nil {
35 | return err
36 | }
37 | resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
38 | if err != nil {
39 | return err
40 | }
41 | var res barkMessageResponse
42 | err = json.NewDecoder(resp.Body).Decode(&res)
43 | if err != nil {
44 | return err
45 | }
46 | if res.Code != 200 {
47 | return errors.New(res.Message)
48 | }
49 | return nil
50 | }
51 |
--------------------------------------------------------------------------------
/channel/group.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "message-pusher/model"
7 | "strings"
8 | )
9 |
10 | func SendGroupMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
11 | subChannels := strings.Split(channel_.AppId, "|")
12 | var subTargets []string
13 | if message.To != "" {
14 | subTargets = strings.Split(message.To, "|")
15 | } else {
16 | subTargets = strings.Split(channel_.AccountId, "|")
17 | }
18 | if len(subChannels) != len(subTargets) {
19 | return errors.New("无效的群组消息配置,子通道数量与子目标数量不一致")
20 | }
21 | errMessage := ""
22 | for i := 0; i < len(subChannels); i++ {
23 | message.To = subTargets[i]
24 | message.Channel = subChannels[i]
25 | subChannel, err := model.GetChannelByName(subChannels[i], user.Id)
26 | if err != nil {
27 | return errors.New("获取群组消息子通道失败:" + err.Error())
28 | }
29 | if subChannel.Type == model.TypeGroup {
30 | return errors.New("群组消息子通道不能是群组消息")
31 | }
32 | err = SendMessage(message, user, subChannel)
33 | if err != nil {
34 | errMessage += fmt.Sprintf("发送群组消息子通道 %s 失败:%s\n", subChannels[i], err.Error())
35 | }
36 | }
37 | if errMessage != "" {
38 | return errors.New(errMessage)
39 | }
40 | return nil
41 | }
42 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-template",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "axios": "^0.27.2",
7 | "history": "^5.3.0",
8 | "marked": "^4.1.1",
9 | "react": "^18.2.0",
10 | "react-dom": "^18.2.0",
11 | "react-router-dom": "^6.3.0",
12 | "react-scripts": "5.0.1",
13 | "react-toastify": "^9.0.8",
14 | "react-turnstile": "^1.0.5",
15 | "semantic-ui-css": "^2.5.0",
16 | "semantic-ui-react": "^2.1.3"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "test": "react-scripts test",
22 | "eject": "react-scripts eject"
23 | },
24 | "eslintConfig": {
25 | "extends": [
26 | "react-app",
27 | "react-app/jest"
28 | ]
29 | },
30 | "browserslist": {
31 | "production": [
32 | ">0.2%",
33 | "not dead",
34 | "not op_mini all"
35 | ],
36 | "development": [
37 | "last 1 chrome version",
38 | "last 1 firefox version",
39 | "last 1 safari version"
40 | ]
41 | },
42 | "devDependencies": {
43 | "prettier": "^2.7.1"
44 | },
45 | "prettier": {
46 | "singleQuote": true,
47 | "jsxSingleQuote": true
48 | },
49 | "proxy": "http://localhost:3000"
50 | }
51 |
--------------------------------------------------------------------------------
/.github/workflows/macos-release.yml:
--------------------------------------------------------------------------------
1 | name: macOS Release
2 | permissions:
3 | contents: write
4 |
5 | on:
6 | push:
7 | tags:
8 | - '*'
9 | jobs:
10 | release:
11 | runs-on: macos-latest
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v3
15 | with:
16 | fetch-depth: 0
17 | - uses: actions/setup-node@v3
18 | with:
19 | node-version: 16
20 | - name: Build Frontend
21 | env:
22 | CI: ""
23 | run: |
24 | cd web
25 | npm install
26 | REACT_APP_VERSION=$(git describe --tags) npm run build
27 | cd ..
28 | - name: Set up Go
29 | uses: actions/setup-go@v3
30 | with:
31 | go-version: '>=1.18.0'
32 | - name: Build Backend
33 | run: |
34 | go mod download
35 | go build -ldflags "-X 'message-pusher/common.Version=$(git describe --tags)'" -o message-pusher-macos
36 | - name: Release
37 | uses: softprops/action-gh-release@v1
38 | if: startsWith(github.ref, 'refs/tags/')
39 | with:
40 | files: message-pusher-macos
41 | draft: true
42 | generate_release_notes: true
43 | env:
44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45 |
--------------------------------------------------------------------------------
/common/logger.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "fmt"
5 | "github.com/gin-gonic/gin"
6 | "io"
7 | "log"
8 | "os"
9 | "path/filepath"
10 | "time"
11 | )
12 |
13 | func SetupGinLog() {
14 | if *LogDir != "" {
15 | commonLogPath := filepath.Join(*LogDir, "common.log")
16 | errorLogPath := filepath.Join(*LogDir, "error.log")
17 | commonFd, err := os.OpenFile(commonLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
18 | if err != nil {
19 | log.Fatal("failed to open log file")
20 | }
21 | errorFd, err := os.OpenFile(errorLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
22 | if err != nil {
23 | log.Fatal("failed to open log file")
24 | }
25 | gin.DefaultWriter = io.MultiWriter(os.Stdout, commonFd)
26 | gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, errorFd)
27 | }
28 | }
29 |
30 | func SysLog(s string) {
31 | t := time.Now()
32 | _, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
33 | }
34 |
35 | func SysError(s string) {
36 | t := time.Now()
37 | _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
38 | }
39 |
40 | func FatalLog(v ...any) {
41 | t := time.Now()
42 | _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
43 | os.Exit(1)
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/windows-release.yml:
--------------------------------------------------------------------------------
1 | name: Windows Release
2 | permissions:
3 | contents: write
4 |
5 | on:
6 | push:
7 | tags:
8 | - '*'
9 | jobs:
10 | release:
11 | runs-on: windows-latest
12 | defaults:
13 | run:
14 | shell: bash
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v3
18 | with:
19 | fetch-depth: 0
20 | - uses: actions/setup-node@v3
21 | with:
22 | node-version: 16
23 | - name: Build Frontend
24 | env:
25 | CI: ""
26 | run: |
27 | cd web
28 | npm install
29 | REACT_APP_VERSION=$(git describe --tags) npm run build
30 | cd ..
31 | - name: Set up Go
32 | uses: actions/setup-go@v3
33 | with:
34 | go-version: '>=1.18.0'
35 | - name: Build Backend
36 | run: |
37 | go mod download
38 | go build -ldflags "-s -w -X 'message-pusher/common.Version=$(git describe --tags)'" -o message-pusher.exe
39 | - name: Release
40 | uses: softprops/action-gh-release@v1
41 | if: startsWith(github.ref, 'refs/tags/')
42 | with:
43 | files: message-pusher.exe
44 | draft: true
45 | generate_release_notes: true
46 | env:
47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/web/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import { Container, Segment } from 'semantic-ui-react';
4 |
5 | const Footer = () => {
6 | const [Footer, setFooter] = useState('');
7 | useEffect(() => {
8 | let savedFooter = localStorage.getItem('footer_html');
9 | if (!savedFooter) savedFooter = '';
10 | setFooter(savedFooter);
11 | });
12 |
13 | return (
14 |
15 |
16 | {Footer === '' ? (
17 |
33 | ) : (
34 |
38 | )}
39 |
40 |
41 | );
42 | };
43 |
44 | export default Footer;
45 |
--------------------------------------------------------------------------------
/web/src/pages/Setting/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Segment, Tab } from 'semantic-ui-react';
3 | import SystemSetting from '../../components/SystemSetting';
4 | import { isRoot } from '../../helpers';
5 | import OtherSetting from '../../components/OtherSetting';
6 | import PersonalSetting from '../../components/PersonalSetting';
7 | import PushSetting from '../../components/PushSetting';
8 |
9 | const Setting = () => {
10 | let panes = [
11 | {
12 | menuItem: '个人设置',
13 | render: () => (
14 |
15 |
16 |
17 | ),
18 | },
19 | {
20 | menuItem: '推送设置',
21 | render: () => (
22 |
23 |
24 |
25 | ),
26 | },
27 | ];
28 |
29 | if (isRoot()) {
30 | panes.push({
31 | menuItem: '系统设置',
32 | render: () => (
33 |
34 |
35 |
36 | ),
37 | });
38 | panes.push({
39 | menuItem: '其他设置',
40 | render: () => (
41 |
42 |
43 |
44 | ),
45 | });
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default Setting;
56 |
--------------------------------------------------------------------------------
/middleware/auth.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-contrib/sessions"
5 | "github.com/gin-gonic/gin"
6 | "message-pusher/common"
7 | "net/http"
8 | )
9 |
10 | func authHelper(c *gin.Context, minRole int) {
11 | session := sessions.Default(c)
12 | username := session.Get("username")
13 | role := session.Get("role")
14 | id := session.Get("id")
15 | status := session.Get("status")
16 | if username == nil {
17 | c.JSON(http.StatusUnauthorized, gin.H{
18 | "success": false,
19 | "message": "无权进行此操作,未登录",
20 | })
21 | c.Abort()
22 | return
23 | }
24 | if status.(int) == common.UserStatusDisabled {
25 | c.JSON(http.StatusOK, gin.H{
26 | "success": false,
27 | "message": "用户已被封禁",
28 | })
29 | c.Abort()
30 | return
31 | }
32 | if role.(int) < minRole {
33 | c.JSON(http.StatusOK, gin.H{
34 | "success": false,
35 | "message": "无权进行此操作,权限不足",
36 | })
37 | c.Abort()
38 | return
39 | }
40 | c.Set("username", username)
41 | c.Set("role", role)
42 | c.Set("id", id)
43 | c.Next()
44 | }
45 |
46 | func UserAuth() func(c *gin.Context) {
47 | return func(c *gin.Context) {
48 | authHelper(c, common.RoleCommonUser)
49 | }
50 | }
51 |
52 | func AdminAuth() func(c *gin.Context) {
53 | return func(c *gin.Context) {
54 | authHelper(c, common.RoleAdminUser)
55 | }
56 | }
57 |
58 | func RootAuth() func(c *gin.Context) {
59 | return func(c *gin.Context) {
60 | authHelper(c, common.RoleRootUser)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/controller/message-sse.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "io"
6 | "message-pusher/model"
7 | "sync"
8 | )
9 |
10 | var messageChanBufferSize = 10
11 |
12 | var messageChanStore struct {
13 | Map map[int]*chan *model.Message
14 | Mutex sync.RWMutex
15 | }
16 |
17 | func messageChanStoreAdd(messageChan *chan *model.Message, userId int) {
18 | messageChanStore.Mutex.Lock()
19 | defer messageChanStore.Mutex.Unlock()
20 | messageChanStore.Map[userId] = messageChan
21 | }
22 |
23 | func messageChanStoreRemove(userId int) {
24 | messageChanStore.Mutex.Lock()
25 | defer messageChanStore.Mutex.Unlock()
26 | delete(messageChanStore.Map, userId)
27 | }
28 |
29 | func init() {
30 | messageChanStore.Map = make(map[int]*chan *model.Message)
31 | }
32 |
33 | func syncMessageToUser(message *model.Message, userId int) {
34 | messageChanStore.Mutex.RLock()
35 | defer messageChanStore.Mutex.RUnlock()
36 | messageChan, ok := messageChanStore.Map[userId]
37 | if !ok {
38 | return
39 | }
40 | *messageChan <- message
41 | }
42 |
43 | func GetNewMessages(c *gin.Context) {
44 | userId := c.GetInt("id")
45 | messageChan := make(chan *model.Message, messageChanBufferSize)
46 | messageChanStoreAdd(&messageChan, userId)
47 | c.Stream(func(w io.Writer) bool {
48 | if msg, ok := <-messageChan; ok {
49 | c.SSEvent("message", *msg)
50 | return true
51 | }
52 | return false
53 | })
54 | messageChanStoreRemove(userId)
55 | close(messageChan)
56 | }
57 |
--------------------------------------------------------------------------------
/docs/API.md:
--------------------------------------------------------------------------------
1 | # API 文档
2 |
3 | ## WebSocket 客户端
4 | 你可以使用 WebSocket 客户端连接服务器,具体的客户端的类型可以是桌面应用,手机应用或 Web 应用等,只要遵循下述协议即可。
5 |
6 | 目前同一时间一个用户只能有一个客户端连接到服务器,之前已连接的客户端将被断开连接。
7 |
8 | ### 连接协议
9 | 1. API 端点为:`ws://:/api/register_client/?secret=`
10 | 2. 如果启用了 HTTPS,则需要将 `ws` 替换为 `wss`。
11 | 3. 上述 `secret` 为用户在后台设置的 `服务器连接密钥`,而非 `推送 token`。
12 |
13 | ### 接收消息
14 | 1. 消息编码格式为 JSON。
15 | 2. 具体内容:
16 | ```json
17 | {
18 | "title": "标题",
19 | "description": "描述",
20 | "content": "内容",
21 | "html_content": "转换为 HTML 后的内容",
22 | "url": "链接"
23 | }
24 | ```
25 | 可能还有多余字段,忽略即可。
26 |
27 | ### 连接保活
28 | 1. 每 `56s` 服务器将发送 `ping` 报文,客户端需要在 `60s` 内回复 `pong` 报文,否则服务端将不再维护该连接。
29 | 2. 服务端会主动回复客户端发来的 `ping` 报文。
30 |
31 | ### 实现列表
32 | 当前可用的 WebSocket 客户端实现有:
33 | 1. 官方 WebSocket 桌面客户端实现:https://github.com/songquanpeng/personal-assistant
34 | 2. 待补充
35 |
36 | 欢迎在此提交你的客户端实现。
37 |
38 |
39 | ## 通过消息 UUID 获取消息发送状态
40 | 1. API 端点为:`https://:/api/message/status/`
41 | 2. 由于使用的是消息的 UUID 而非 ID,因此此处不需要鉴权,
42 | 3. 返回内容示例:
43 | ```json
44 | {
45 | "success": true,
46 | "message": "",
47 | "status": 2
48 | }
49 | ```
50 | 4. 返回内容字段:
51 | 1. `success`:本次请求是否成功
52 | 2. `message`:错误信息
53 | 3. `status`:消息状态码。
54 | 5. 消息状态码定义如下:
55 | ```
56 | MessageSendStatusUnknown = 0
57 | MessageSendStatusPending = 1
58 | MessageSendStatusSent = 2
59 | MessageSendStatusFailed = 3
60 | MessageSendStatusAsyncPending = 4
61 | ```
--------------------------------------------------------------------------------
/web/src/pages/About/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Header, Segment } from 'semantic-ui-react';
3 | import { API, showError } from '../../helpers';
4 | import { marked } from 'marked';
5 |
6 | const About = () => {
7 | const [about, setAbout] = useState('');
8 | const [aboutLoaded, setAboutLoaded] = useState(false);
9 |
10 | const displayAbout = async () => {
11 | setAbout(localStorage.getItem('about') || '');
12 | const res = await API.get('/api/about');
13 | const { success, message, data } = res.data;
14 | if (success) {
15 | let HTMLAbout = marked.parse(data);
16 | setAbout(HTMLAbout);
17 | localStorage.setItem('about', HTMLAbout);
18 | } else {
19 | showError(message);
20 | setAbout('加载关于内容失败...');
21 | }
22 | setAboutLoaded(true);
23 | };
24 |
25 | useEffect(() => {
26 | displayAbout().then();
27 | }, []);
28 |
29 | return (
30 | <>
31 |
32 | {aboutLoaded && about === '' ? (
33 | <>
34 |
35 | 可在设置页面设置关于内容,支持 HTML & Markdown
36 | 项目仓库地址:
37 |
38 | https://github.com/songquanpeng/message-pusher
39 |
40 | >
41 | ) : (
42 | <>
43 |
44 | >
45 | )}
46 |
47 | >
48 | );
49 | };
50 |
51 |
52 | export default About;
53 |
--------------------------------------------------------------------------------
/controller/websocket.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/gorilla/websocket"
6 | "message-pusher/channel"
7 | "message-pusher/model"
8 | "net/http"
9 | )
10 |
11 | var upgrader = websocket.Upgrader{
12 | ReadBufferSize: 1024,
13 | WriteBufferSize: 1024,
14 | CheckOrigin: func(r *http.Request) bool {
15 | return true
16 | },
17 | }
18 |
19 | func RegisterClient(c *gin.Context) {
20 | secret := c.Query("secret")
21 | if secret == "" {
22 | c.JSON(http.StatusOK, gin.H{
23 | "success": false,
24 | "message": "secret 为空",
25 | })
26 | return
27 | }
28 | user := model.User{Username: c.Param("username")}
29 | err := user.FillUserByUsername()
30 | if err != nil {
31 | c.JSON(http.StatusOK, gin.H{
32 | "success": false,
33 | "message": "无效的用户名",
34 | })
35 | return
36 | }
37 | channelName := c.Query("channel")
38 | if channelName == "" {
39 | channelName = "client"
40 | }
41 | channel_, err := model.GetChannelByName(channelName, user.Id)
42 | if err != nil {
43 | c.JSON(http.StatusOK, gin.H{
44 | "success": false,
45 | "message": "无效的通道名称",
46 | })
47 | return
48 | }
49 | if secret != channel_.Secret {
50 | c.JSON(http.StatusOK, gin.H{
51 | "success": false,
52 | "message": "通道名称与密钥不匹配",
53 | })
54 | return
55 | }
56 | conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
57 | if err != nil {
58 | c.JSON(http.StatusOK, gin.H{
59 | "success": false,
60 | "message": err.Error(),
61 | })
62 | return
63 | }
64 | channel.RegisterClient(channelName, user.Id, conn)
65 | return
66 | }
67 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image-amd64.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image (amd64)
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 | inputs:
9 | name:
10 | description: 'reason'
11 | required: false
12 | jobs:
13 | push_to_registries:
14 | name: Push Docker image to multiple registries
15 | runs-on: ubuntu-latest
16 | permissions:
17 | packages: write
18 | contents: read
19 | steps:
20 | - name: Check out the repo
21 | uses: actions/checkout@v3
22 |
23 | - name: Save version info
24 | run: |
25 | git describe --tags > VERSION
26 |
27 | - name: Log in to Docker Hub
28 | uses: docker/login-action@v2
29 | with:
30 | username: ${{ secrets.DOCKERHUB_USERNAME }}
31 | password: ${{ secrets.DOCKERHUB_TOKEN }}
32 |
33 | - name: Log in to the Container registry
34 | uses: docker/login-action@v2
35 | with:
36 | registry: ghcr.io
37 | username: ${{ github.actor }}
38 | password: ${{ secrets.GITHUB_TOKEN }}
39 |
40 | - name: Extract metadata (tags, labels) for Docker
41 | id: meta
42 | uses: docker/metadata-action@v4
43 | with:
44 | images: |
45 | justsong/message-pusher
46 | ghcr.io/${{ github.repository }}
47 |
48 | - name: Build and push Docker images
49 | uses: docker/build-push-action@v3
50 | with:
51 | context: .
52 | push: true
53 | tags: ${{ steps.meta.outputs.tags }}
54 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/channel/main.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "errors"
5 | "message-pusher/model"
6 | )
7 |
8 | func SendMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
9 | switch channel_.Type {
10 | case model.TypeEmail:
11 | return SendEmailMessage(message, user, channel_)
12 | case model.TypeWeChatTestAccount:
13 | return SendWeChatTestMessage(message, user, channel_)
14 | case model.TypeWeChatCorpAccount:
15 | return SendWeChatCorpMessage(message, user, channel_)
16 | case model.TypeCorp:
17 | return SendCorpMessage(message, user, channel_)
18 | case model.TypeLark:
19 | return SendLarkMessage(message, user, channel_)
20 | case model.TypeDing:
21 | return SendDingMessage(message, user, channel_)
22 | case model.TypeBark:
23 | return SendBarkMessage(message, user, channel_)
24 | case model.TypeClient:
25 | return SendClientMessage(message, user, channel_)
26 | case model.TypeTelegram:
27 | return SendTelegramMessage(message, user, channel_)
28 | case model.TypeDiscord:
29 | return SendDiscordMessage(message, user, channel_)
30 | case model.TypeNone:
31 | return nil
32 | case model.TypeOneBot:
33 | return SendOneBotMessage(message, user, channel_)
34 | case model.TypeGroup:
35 | return SendGroupMessage(message, user, channel_)
36 | case model.TypeLarkApp:
37 | return SendLarkAppMessage(message, user, channel_)
38 | case model.TypeCustom:
39 | return SendCustomMessage(message, user, channel_)
40 | case model.TypeTencentAlarm:
41 | return SendTencentAlarmMessage(message, user, channel_)
42 | default:
43 | return errors.New("不支持的消息通道:" + channel_.Type)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/channel/discord.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "message-pusher/model"
8 | "net/http"
9 | "strings"
10 | )
11 |
12 | type discordMessageRequest struct {
13 | Content string `json:"content"`
14 | }
15 |
16 | type discordMessageResponse struct {
17 | Code int `json:"code"`
18 | Message string `json:"message"`
19 | }
20 |
21 | func SendDiscordMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
22 | if message.Content == "" {
23 | message.Content = message.Description
24 | }
25 | messageRequest := discordMessageRequest{
26 | Content: message.Content,
27 | }
28 | // https://discord.com/developers/docs/reference#message-formatting
29 | if message.To != "" {
30 | messageRequest.Content = ""
31 | ids := strings.Split(message.To, "|")
32 | for _, id := range ids {
33 | messageRequest.Content = "<@" + id + "> " + messageRequest.Content
34 | }
35 | messageRequest.Content = messageRequest.Content + message.Content
36 | }
37 |
38 | jsonData, err := json.Marshal(messageRequest)
39 | if err != nil {
40 | return err
41 | }
42 | resp, err := http.Post(channel_.URL, "application/json", bytes.NewBuffer(jsonData))
43 | if err != nil {
44 | return err
45 | }
46 | if resp.StatusCode == http.StatusNoContent {
47 | return nil
48 | }
49 | var res discordMessageResponse
50 | err = json.NewDecoder(resp.Body).Decode(&res)
51 | if err != nil {
52 | return err
53 | }
54 | if res.Code != 0 {
55 | return errors.New(res.Message)
56 | }
57 | if resp.StatusCode == http.StatusBadRequest {
58 | return errors.New(resp.Status)
59 | }
60 | return nil
61 | }
62 |
--------------------------------------------------------------------------------
/channel/corp.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "message-pusher/model"
9 | "net/http"
10 | "strings"
11 | )
12 |
13 | type corpMessageRequest struct {
14 | MessageType string `json:"msgtype"`
15 | Text struct {
16 | Content string `json:"content"`
17 | } `json:"text"`
18 | Markdown struct {
19 | Content string `json:"content"`
20 | } `json:"markdown"`
21 | MentionedList []string `json:"mentioned_list"`
22 | }
23 |
24 | type corpMessageResponse struct {
25 | Code int `json:"errcode"`
26 | Message string `json:"errmsg"`
27 | }
28 |
29 | func SendCorpMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
30 | // https://developer.work.weixin.qq.com/document/path/91770
31 | messageRequest := corpMessageRequest{
32 | MessageType: "text",
33 | }
34 | if message.Content == "" {
35 | messageRequest.MessageType = "text"
36 | messageRequest.Text.Content = message.Description
37 | } else {
38 | messageRequest.MessageType = "markdown"
39 | messageRequest.Markdown.Content = message.Content
40 | }
41 | if message.To != "" {
42 | messageRequest.MentionedList = strings.Split(message.To, "|")
43 | }
44 | jsonData, err := json.Marshal(messageRequest)
45 | if err != nil {
46 | return err
47 | }
48 | resp, err := http.Post(fmt.Sprintf("%s", channel_.URL), "application/json",
49 | bytes.NewBuffer(jsonData))
50 | if err != nil {
51 | return err
52 | }
53 | var res corpMessageResponse
54 | err = json.NewDecoder(resp.Body).Decode(&res)
55 | if err != nil {
56 | return err
57 | }
58 | if res.Code != 0 {
59 | return errors.New(res.Message)
60 | }
61 | return nil
62 | }
63 |
--------------------------------------------------------------------------------
/web/src/constants/channel.constants.js:
--------------------------------------------------------------------------------
1 | export const CHANNEL_OPTIONS = [
2 | { key: 'email', text: '邮件', value: 'email', color: '#4285f4' },
3 | { key: 'test', text: '微信测试号', value: 'test', color: '#2cbb00' },
4 | {
5 | key: 'corp_app',
6 | text: '企业微信应用号',
7 | value: 'corp_app',
8 | color: '#5fc9ec',
9 | },
10 | { key: 'corp', text: '企业微信群机器人', value: 'corp', color: '#019d82' },
11 | { key: 'lark', text: '飞书群机器人', value: 'lark', color: '#00d6b9' },
12 | {
13 | key: 'lark_app',
14 | text: '飞书自建应用',
15 | value: 'lark_app',
16 | color: '#0d71fe',
17 | },
18 | { key: 'ding', text: '钉钉群机器人', value: 'ding', color: '#007fff' },
19 | { key: 'bark', text: 'Bark App', value: 'bark', color: '#ff3b30' },
20 | {
21 | key: 'client',
22 | text: 'WebSocket 客户端',
23 | value: 'client',
24 | color: '#121212',
25 | },
26 | {
27 | key: 'telegram',
28 | text: 'Telegram 机器人',
29 | value: 'telegram',
30 | color: '#29a9ea',
31 | },
32 | {
33 | key: 'discord',
34 | text: 'Discord 群机器人',
35 | value: 'discord',
36 | color: '#404eed',
37 | },
38 | {
39 | key: 'one_bot',
40 | text: 'OneBot 协议',
41 | value: 'one_bot',
42 | color: '#76FF03',
43 | },
44 | {
45 | key: 'custom',
46 | text: '自定义消息通道',
47 | value: 'custom',
48 | color: '#cc33ff',
49 | },
50 | {
51 | key: 'group',
52 | text: '群组消息',
53 | value: 'group',
54 | color: '#FF9800',
55 | },
56 | {
57 | key: 'tencent_alarm',
58 | text: '腾讯云消息告警',
59 | value: 'tencent_alarm',
60 | color: '#00a4ff',
61 | },
62 | {
63 | key: 'none',
64 | text: '不推送',
65 | value: 'none',
66 | color: '#808080',
67 | },
68 | ];
69 |
--------------------------------------------------------------------------------
/.github/workflows/linux-release.yml:
--------------------------------------------------------------------------------
1 | name: Linux Release
2 | permissions:
3 | contents: write
4 |
5 | on:
6 | push:
7 | tags:
8 | - '*'
9 | jobs:
10 | release:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v3
15 | with:
16 | fetch-depth: 0
17 | - uses: actions/setup-node@v3
18 | with:
19 | node-version: 16
20 | - name: Build Frontend
21 | env:
22 | CI: ""
23 | run: |
24 | cd web
25 | npm install
26 | REACT_APP_VERSION=$(git describe --tags) npm run build
27 | cd ..
28 | - name: Set up Go
29 | uses: actions/setup-go@v3
30 | with:
31 | go-version: '>=1.18.0'
32 | - name: Build Backend (amd64)
33 | run: |
34 | go mod download
35 | go build -ldflags "-s -w -X 'message-pusher/common.Version=$(git describe --tags)' -extldflags '-static'" -o message-pusher
36 | - name: Build Backend (arm64)
37 | run: |
38 | sudo apt-get update
39 | sudo apt-get install gcc-aarch64-linux-gnu
40 | CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'message-pusher/common.Version=$(git describe --tags)' -extldflags '-static'" -o message-pusher-arm64
41 | - name: Release
42 | uses: softprops/action-gh-release@v1
43 | if: startsWith(github.ref, 'refs/tags/')
44 | with:
45 | files: |
46 | message-pusher
47 | message-pusher-arm64
48 | draft: true
49 | generate_release_notes: true
50 | env:
51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/common/init.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | )
11 |
12 | var (
13 | PrintVersion = flag.Bool("version", false, "Print the version of the program and exits.")
14 | PrintHelp = flag.Bool("help", false, "Print the help message and exits.")
15 | Port = flag.Int("port", 3000, "Specify the listening port. Default is 3000.")
16 | LogDir = flag.String("log-dir", "", "Specify the directory for log files.")
17 | )
18 |
19 | func printHelp() {
20 | fmt.Println(fmt.Sprintf("Message Pusher %s - Your all in one message push system.", Version))
21 | fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
22 | fmt.Println("GitHub: https://github.com/songquanpeng/message-pusher")
23 | fmt.Println("Usage: message-pusher [options]")
24 | fmt.Println("Options:")
25 | flag.CommandLine.VisitAll(func(f *flag.Flag) {
26 | name := fmt.Sprintf("-%s", f.Name)
27 | usage := strings.Replace(f.Usage, "\n", "\n ", -1)
28 | fmt.Printf(" -%-14s%s\n", name, usage)
29 | })
30 | os.Exit(0)
31 | }
32 |
33 | func init() {
34 | flag.Parse()
35 |
36 | if *PrintVersion {
37 | fmt.Println(Version)
38 | os.Exit(0)
39 | }
40 |
41 | if *PrintHelp {
42 | printHelp()
43 | }
44 |
45 | if os.Getenv("SESSION_SECRET") != "" {
46 | SessionSecret = os.Getenv("SESSION_SECRET")
47 | }
48 | if os.Getenv("SQLITE_PATH") != "" {
49 | SQLitePath = os.Getenv("SQLITE_PATH")
50 | }
51 | if *LogDir != "" {
52 | var err error
53 | *LogDir, err = filepath.Abs(*LogDir)
54 | if err != nil {
55 | log.Fatal(err)
56 | }
57 | if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
58 | err = os.Mkdir(*LogDir, 0777)
59 | if err != nil {
60 | log.Fatal(err)
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/common/rate-limit.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | type InMemoryRateLimiter struct {
9 | store map[string]*[]int64
10 | mutex sync.Mutex
11 | expirationDuration time.Duration
12 | }
13 |
14 | func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
15 | if l.store == nil {
16 | l.mutex.Lock()
17 | if l.store == nil {
18 | l.store = make(map[string]*[]int64)
19 | l.expirationDuration = expirationDuration
20 | if expirationDuration > 0 {
21 | go l.clearExpiredItems()
22 | }
23 | }
24 | l.mutex.Unlock()
25 | }
26 | }
27 |
28 | func (l *InMemoryRateLimiter) clearExpiredItems() {
29 | for {
30 | time.Sleep(l.expirationDuration)
31 | l.mutex.Lock()
32 | now := time.Now().Unix()
33 | for key := range l.store {
34 | queue := l.store[key]
35 | size := len(*queue)
36 | if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
37 | delete(l.store, key)
38 | }
39 | }
40 | l.mutex.Unlock()
41 | }
42 | }
43 |
44 | // Request parameter duration's unit is seconds
45 | func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
46 | l.mutex.Lock()
47 | defer l.mutex.Unlock()
48 | // [old <-- new]
49 | queue, ok := l.store[key]
50 | now := time.Now().Unix()
51 | if ok {
52 | if len(*queue) < maxRequestNum {
53 | *queue = append(*queue, now)
54 | return true
55 | } else {
56 | if now-(*queue)[0] >= duration {
57 | *queue = (*queue)[1:]
58 | *queue = append(*queue, now)
59 | return true
60 | } else {
61 | return false
62 | }
63 | }
64 | } else {
65 | s := make([]int64, 0, maxRequestNum)
66 | l.store[key] = &s
67 | *(l.store[key]) = append(*(l.store[key]), now)
68 | }
69 | return true
70 | }
71 |
--------------------------------------------------------------------------------
/channel/message-queue.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "message-pusher/common"
5 | "message-pusher/model"
6 | )
7 |
8 | var AsyncMessageQueue chan int
9 | var AsyncMessageQueueSize = 128
10 | var AsyncMessageSenderNum = 2
11 |
12 | func init() {
13 | AsyncMessageQueue = make(chan int, AsyncMessageQueueSize)
14 | for i := 0; i < AsyncMessageSenderNum; i++ {
15 | go asyncMessageSender()
16 | }
17 | }
18 |
19 | // LoadAsyncMessages loads async pending messages from database.
20 | // We have to wait the database connection is ready.
21 | func LoadAsyncMessages() {
22 | ids, err := model.GetAsyncPendingMessageIds()
23 | if err != nil {
24 | common.FatalLog("failed to load async pending messages: " + err.Error())
25 | }
26 | for _, id := range ids {
27 | AsyncMessageQueue <- id
28 | }
29 | }
30 |
31 | func asyncMessageSenderHelper(message *model.Message) error {
32 | user, err := model.GetUserById(message.UserId, false)
33 | if err != nil {
34 | return err
35 | }
36 | channel_, err := model.GetChannelByName(message.Channel, user.Id)
37 | if err != nil {
38 | return err
39 | }
40 | return SendMessage(message, user, channel_)
41 | }
42 |
43 | func asyncMessageSender() {
44 | for {
45 | id := <-AsyncMessageQueue
46 | message, err := model.GetMessageById(id)
47 | if err != nil {
48 | common.SysError("async message sender error: " + err.Error())
49 | continue
50 | }
51 | err = asyncMessageSenderHelper(message)
52 | status := common.MessageSendStatusFailed
53 | if err != nil {
54 | common.SysError("async message sender error: " + err.Error())
55 | } else {
56 | status = common.MessageSendStatusSent
57 | }
58 | err = message.UpdateStatus(status)
59 | if err != nil {
60 | common.SysError("async message sender error: " + err.Error())
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/common/email.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "crypto/tls"
5 | "encoding/base64"
6 | "fmt"
7 | "net/smtp"
8 | "strings"
9 | )
10 |
11 | func SendEmail(subject string, receiver string, content string) error {
12 | encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
13 | mail := []byte(fmt.Sprintf("To: %s\r\n"+
14 | "From: %s<%s>\r\n"+
15 | "Subject: %s\r\n"+
16 | "Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
17 | receiver, SystemName, SMTPAccount, encodedSubject, content))
18 | auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
19 | addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
20 | to := strings.Split(receiver, ";")
21 | var err error
22 | if SMTPPort == 465 {
23 | tlsConfig := &tls.Config{
24 | InsecureSkipVerify: true,
25 | ServerName: SMTPServer,
26 | }
27 | conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig)
28 | if err != nil {
29 | return err
30 | }
31 | client, err := smtp.NewClient(conn, SMTPServer)
32 | if err != nil {
33 | return err
34 | }
35 | defer client.Close()
36 | if err = client.Auth(auth); err != nil {
37 | return err
38 | }
39 | if err = client.Mail(SMTPAccount); err != nil {
40 | return err
41 | }
42 | receiverEmails := strings.Split(receiver, ";")
43 | for _, receiver := range receiverEmails {
44 | if err = client.Rcpt(receiver); err != nil {
45 | return err
46 | }
47 | }
48 | w, err := client.Data()
49 | if err != nil {
50 | return err
51 | }
52 | _, err = w.Write(mail)
53 | if err != nil {
54 | return err
55 | }
56 | err = w.Close()
57 | if err != nil {
58 | return err
59 | }
60 | } else {
61 | err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
62 | }
63 | return err
64 | }
65 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image-arm64.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image (arm64)
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 | inputs:
9 | name:
10 | description: 'reason'
11 | required: false
12 | jobs:
13 | push_to_registries:
14 | name: Push Docker image to multiple registries
15 | runs-on: ubuntu-latest
16 | permissions:
17 | packages: write
18 | contents: read
19 | steps:
20 | - name: Check out the repo
21 | uses: actions/checkout@v3
22 |
23 | - name: Save version info
24 | run: |
25 | git describe --tags > VERSION
26 |
27 | - name: Set up QEMU
28 | uses: docker/setup-qemu-action@v2
29 |
30 | - name: Set up Docker Buildx
31 | uses: docker/setup-buildx-action@v2
32 |
33 | - name: Log in to Docker Hub
34 | uses: docker/login-action@v2
35 | with:
36 | username: ${{ secrets.DOCKERHUB_USERNAME }}
37 | password: ${{ secrets.DOCKERHUB_TOKEN }}
38 |
39 | - name: Log in to the Container registry
40 | uses: docker/login-action@v2
41 | with:
42 | registry: ghcr.io
43 | username: ${{ github.actor }}
44 | password: ${{ secrets.GITHUB_TOKEN }}
45 |
46 | - name: Extract metadata (tags, labels) for Docker
47 | id: meta
48 | uses: docker/metadata-action@v4
49 | with:
50 | images: |
51 | justsong/message-pusher
52 | ghcr.io/${{ github.repository }}
53 |
54 | - name: Build and push Docker images
55 | uses: docker/build-push-action@v3
56 | with:
57 | context: .
58 | platforms: linux/amd64,linux/arm64
59 | push: true
60 | tags: ${{ steps.meta.outputs.tags }}
61 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/web/src/components/GitHubOAuth.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState } from 'react';
2 | import { Dimmer, Loader, Segment } from 'semantic-ui-react';
3 | import { useNavigate, useSearchParams } from 'react-router-dom';
4 | import { API, showError, showSuccess } from '../helpers';
5 | import { UserContext } from '../context/User';
6 |
7 | const GitHubOAuth = () => {
8 | const [searchParams, setSearchParams] = useSearchParams();
9 |
10 | const [userState, userDispatch] = useContext(UserContext);
11 | const [prompt, setPrompt] = useState('处理中...');
12 | const [processing, setProcessing] = useState(true);
13 |
14 | let navigate = useNavigate();
15 |
16 | const sendCode = async (code, count) => {
17 | const res = await API.get(`/api/oauth/github?code=${code}`);
18 | const { success, message, data } = res.data;
19 | if (success) {
20 | if (message === 'bind') {
21 | showSuccess('绑定成功!');
22 | navigate('/setting');
23 | } else {
24 | userDispatch({ type: 'login', payload: data });
25 | localStorage.setItem('user', JSON.stringify(data));
26 | showSuccess('登录成功!');
27 | navigate('/');
28 | }
29 | } else {
30 | showError(message);
31 | if (count === 0) {
32 | setPrompt(`操作失败,重定向至登录界面中...`);
33 | navigate('/setting'); // in case this is failed to bind GitHub
34 | return;
35 | }
36 | count++;
37 | setPrompt(`出现错误,第 ${count} 次重试中...`);
38 | await new Promise((resolve) => setTimeout(resolve, count * 2000));
39 | await sendCode(code, count);
40 | }
41 | };
42 |
43 | useEffect(() => {
44 | let code = searchParams.get('code');
45 | sendCode(code, 0).then();
46 | }, []);
47 |
48 | return (
49 |
50 |
51 | {prompt}
52 |
53 |
54 | );
55 | };
56 |
57 | export default GitHubOAuth;
58 |
--------------------------------------------------------------------------------
/middleware/turnstile-check.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/gin-contrib/sessions"
6 | "github.com/gin-gonic/gin"
7 | "message-pusher/common"
8 | "net/http"
9 | "net/url"
10 | )
11 |
12 | type turnstileCheckResponse struct {
13 | Success bool `json:"success"`
14 | }
15 |
16 | func TurnstileCheck() gin.HandlerFunc {
17 | return func(c *gin.Context) {
18 | if common.TurnstileCheckEnabled {
19 | session := sessions.Default(c)
20 | turnstileChecked := session.Get("turnstile")
21 | if turnstileChecked != nil {
22 | c.Next()
23 | return
24 | }
25 | response := c.Query("turnstile")
26 | if response == "" {
27 | c.JSON(http.StatusOK, gin.H{
28 | "success": false,
29 | "message": "Turnstile token 为空",
30 | })
31 | c.Abort()
32 | return
33 | }
34 | rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
35 | "secret": {common.TurnstileSecretKey},
36 | "response": {response},
37 | "remoteip": {c.ClientIP()},
38 | })
39 | if err != nil {
40 | common.SysError(err.Error())
41 | c.JSON(http.StatusOK, gin.H{
42 | "success": false,
43 | "message": err.Error(),
44 | })
45 | c.Abort()
46 | return
47 | }
48 | defer rawRes.Body.Close()
49 | var res turnstileCheckResponse
50 | err = json.NewDecoder(rawRes.Body).Decode(&res)
51 | if err != nil {
52 | common.SysError(err.Error())
53 | c.JSON(http.StatusOK, gin.H{
54 | "success": false,
55 | "message": err.Error(),
56 | })
57 | c.Abort()
58 | return
59 | }
60 | if !res.Success {
61 | c.JSON(http.StatusOK, gin.H{
62 | "success": false,
63 | "message": "Turnstile 校验失败,请刷新重试!",
64 | })
65 | c.Abort()
66 | return
67 | }
68 | session.Set("turnstile", true)
69 | err = session.Save()
70 | if err != nil {
71 | c.JSON(http.StatusOK, gin.H{
72 | "message": "无法保存会话信息,请重试",
73 | "success": false,
74 | })
75 | return
76 | }
77 | }
78 | c.Next()
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/common/verification.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "github.com/google/uuid"
5 | "strings"
6 | "sync"
7 | "time"
8 | )
9 |
10 | type verificationValue struct {
11 | code string
12 | time time.Time
13 | }
14 |
15 | const (
16 | EmailVerificationPurpose = "v"
17 | PasswordResetPurpose = "r"
18 | )
19 |
20 | var verificationMutex sync.Mutex
21 | var verificationMap map[string]verificationValue
22 | var verificationMapMaxSize = 10
23 | var VerificationValidMinutes = 10
24 |
25 | func GenerateVerificationCode(length int) string {
26 | code := uuid.New().String()
27 | code = strings.Replace(code, "-", "", -1)
28 | if length == 0 {
29 | return code
30 | }
31 | return code[:length]
32 | }
33 |
34 | func RegisterVerificationCodeWithKey(key string, code string, purpose string) {
35 | verificationMutex.Lock()
36 | defer verificationMutex.Unlock()
37 | verificationMap[purpose+key] = verificationValue{
38 | code: code,
39 | time: time.Now(),
40 | }
41 | if len(verificationMap) > verificationMapMaxSize {
42 | removeExpiredPairs()
43 | }
44 | }
45 |
46 | func VerifyCodeWithKey(key string, code string, purpose string) bool {
47 | verificationMutex.Lock()
48 | defer verificationMutex.Unlock()
49 | value, okay := verificationMap[purpose+key]
50 | now := time.Now()
51 | if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
52 | return false
53 | }
54 | return code == value.code
55 | }
56 |
57 | func DeleteKey(key string, purpose string) {
58 | verificationMutex.Lock()
59 | defer verificationMutex.Unlock()
60 | delete(verificationMap, purpose+key)
61 | }
62 |
63 | // no lock inside, so the caller must lock the verificationMap before calling!
64 | func removeExpiredPairs() {
65 | now := time.Now()
66 | for key := range verificationMap {
67 | if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {
68 | delete(verificationMap, key)
69 | }
70 | }
71 | }
72 |
73 | func init() {
74 | verificationMutex.Lock()
75 | defer verificationMutex.Unlock()
76 | verificationMap = make(map[string]verificationValue)
77 | }
78 |
--------------------------------------------------------------------------------
/controller/option.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/gin-gonic/gin"
6 | "message-pusher/common"
7 | "message-pusher/model"
8 | "net/http"
9 | "strings"
10 | )
11 |
12 | func GetOptions(c *gin.Context) {
13 | var options []*model.Option
14 | common.OptionMapRWMutex.Lock()
15 | for k, v := range common.OptionMap {
16 | if strings.Contains(k, "Token") || strings.Contains(k, "Secret") {
17 | continue
18 | }
19 | options = append(options, &model.Option{
20 | Key: k,
21 | Value: common.Interface2String(v),
22 | })
23 | }
24 | common.OptionMapRWMutex.Unlock()
25 | c.JSON(http.StatusOK, gin.H{
26 | "success": true,
27 | "message": "",
28 | "data": options,
29 | })
30 | return
31 | }
32 |
33 | func UpdateOption(c *gin.Context) {
34 | var option model.Option
35 | err := json.NewDecoder(c.Request.Body).Decode(&option)
36 | if err != nil {
37 | c.JSON(http.StatusBadRequest, gin.H{
38 | "success": false,
39 | "message": "无效的参数",
40 | })
41 | return
42 | }
43 | switch option.Key {
44 | case "GitHubOAuthEnabled":
45 | if option.Value == "true" && common.GitHubClientId == "" {
46 | c.JSON(http.StatusOK, gin.H{
47 | "success": false,
48 | "message": "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!",
49 | })
50 | return
51 | }
52 | case "WeChatAuthEnabled":
53 | if option.Value == "true" && common.WeChatServerAddress == "" {
54 | c.JSON(http.StatusOK, gin.H{
55 | "success": false,
56 | "message": "无法启用微信登录,请先填入微信登录相关配置信息!",
57 | })
58 | return
59 | }
60 | case "TurnstileCheckEnabled":
61 | if option.Value == "true" && common.TurnstileSiteKey == "" {
62 | c.JSON(http.StatusOK, gin.H{
63 | "success": false,
64 | "message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
65 | })
66 | return
67 | }
68 | }
69 | err = model.UpdateOption(option.Key, option.Value)
70 | if err != nil {
71 | c.JSON(http.StatusOK, gin.H{
72 | "success": false,
73 | "message": err.Error(),
74 | })
75 | return
76 | }
77 | c.JSON(http.StatusOK, gin.H{
78 | "success": true,
79 | "message": "",
80 | })
81 | return
82 | }
83 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 | "github.com/gin-contrib/sessions"
6 | "github.com/gin-contrib/sessions/cookie"
7 | "github.com/gin-contrib/sessions/redis"
8 | "github.com/gin-gonic/gin"
9 | "log"
10 | "message-pusher/channel"
11 | "message-pusher/common"
12 | "message-pusher/model"
13 | "message-pusher/router"
14 | "os"
15 | "strconv"
16 | )
17 |
18 | //go:embed web/build
19 | var buildFS embed.FS
20 |
21 | //go:embed web/build/index.html
22 | var indexPage []byte
23 |
24 | func main() {
25 | common.SetupGinLog()
26 | common.SysLog("Message Pusher " + common.Version + " started")
27 | if os.Getenv("GIN_MODE") != "debug" {
28 | gin.SetMode(gin.ReleaseMode)
29 | }
30 | // Initialize SQL Database
31 | err := model.InitDB()
32 | if err != nil {
33 | common.FatalLog(err)
34 | }
35 | go channel.LoadAsyncMessages()
36 | defer func() {
37 | err := model.CloseDB()
38 | if err != nil {
39 | common.FatalLog(err)
40 | }
41 | }()
42 |
43 | // Initialize Redis
44 | err = common.InitRedisClient()
45 | if err != nil {
46 | common.FatalLog(err)
47 | }
48 |
49 | // Initialize options
50 | model.InitOptionMap()
51 |
52 | // Initialize token store
53 | channel.TokenStoreInit()
54 |
55 | // Initialize HTTP server
56 | server := gin.Default()
57 | server.SetHTMLTemplate(common.LoadTemplate())
58 | //server.Use(gzip.Gzip(gzip.DefaultCompression)) // conflict with sse
59 |
60 | // Initialize session store
61 | var store sessions.Store
62 | if common.RedisEnabled {
63 | opt := common.ParseRedisOption()
64 | store, _ = redis.NewStore(opt.MinIdleConns, opt.Network, opt.Addr, opt.Password, []byte(common.SessionSecret))
65 | } else {
66 | store = cookie.NewStore([]byte(common.SessionSecret))
67 | }
68 | store.Options(sessions.Options{
69 | Path: "/",
70 | HttpOnly: true,
71 | MaxAge: 30 * 24 * 3600,
72 | })
73 | server.Use(sessions.Sessions("session", store))
74 |
75 | router.SetRouter(server, buildFS, indexPage)
76 | var port = os.Getenv("PORT")
77 | if port == "" {
78 | port = strconv.Itoa(*common.Port)
79 | }
80 | err = server.Run(":" + port)
81 | if err != nil {
82 | log.Println(err)
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/model/main.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/driver/mysql"
5 | "gorm.io/driver/sqlite"
6 | "gorm.io/gorm"
7 | "message-pusher/common"
8 | "os"
9 | )
10 |
11 | var DB *gorm.DB
12 |
13 | func createRootAccountIfNeed() error {
14 | var user User
15 | //if user.Status != common.UserStatusEnabled {
16 | if err := DB.First(&user).Error; err != nil {
17 | common.SysLog("no user exists, create a root user for you: username is root, password is 123456")
18 | hashedPassword, err := common.Password2Hash("123456")
19 | if err != nil {
20 | return err
21 | }
22 | rootUser := User{
23 | Username: "root",
24 | Password: hashedPassword,
25 | Role: common.RoleRootUser,
26 | Status: common.UserStatusEnabled,
27 | DisplayName: "Root User",
28 | }
29 | DB.Create(&rootUser)
30 | }
31 | return nil
32 | }
33 |
34 | func CountTable(tableName string) (num int64) {
35 | DB.Table(tableName).Count(&num)
36 | return
37 | }
38 |
39 | func InitDB() (err error) {
40 | var db *gorm.DB
41 | if os.Getenv("SQL_DSN") != "" {
42 | // Use MySQL
43 | db, err = gorm.Open(mysql.Open(os.Getenv("SQL_DSN")), &gorm.Config{
44 | PrepareStmt: true, // precompile SQL
45 | })
46 | } else {
47 | // Use SQLite
48 | db, err = gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
49 | PrepareStmt: true, // precompile SQL
50 | })
51 | common.SysLog("SQL_DSN not set, using SQLite as database")
52 | }
53 | if err == nil {
54 | DB = db
55 | err := db.AutoMigrate(&User{})
56 | if err != nil {
57 | return err
58 | }
59 | err = db.AutoMigrate(&Option{})
60 | if err != nil {
61 | return err
62 | }
63 | err = db.AutoMigrate(&Message{})
64 | if err != nil {
65 | return err
66 | }
67 | err = db.AutoMigrate(&Channel{})
68 | if err != nil {
69 | return err
70 | }
71 | err = db.AutoMigrate(&Webhook{})
72 | if err != nil {
73 | return err
74 | }
75 | err = createRootAccountIfNeed()
76 | return err
77 | } else {
78 | common.FatalLog(err)
79 | }
80 | return err
81 | }
82 |
83 | func CloseDB() error {
84 | sqlDB, err := DB.DB()
85 | if err != nil {
86 | return err
87 | }
88 | err = sqlDB.Close()
89 | return err
90 | }
91 |
--------------------------------------------------------------------------------
/web/src/components/PasswordResetConfirm.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
3 | import { API, copy, showError, showSuccess } from '../helpers';
4 | import { useSearchParams } from 'react-router-dom';
5 |
6 | const PasswordResetConfirm = () => {
7 | const [inputs, setInputs] = useState({
8 | email: '',
9 | token: '',
10 | });
11 | const { email, token } = inputs;
12 |
13 | const [loading, setLoading] = useState(false);
14 |
15 | const [searchParams, setSearchParams] = useSearchParams();
16 | useEffect(() => {
17 | let token = searchParams.get('token');
18 | let email = searchParams.get('email');
19 | setInputs({
20 | token,
21 | email,
22 | });
23 | }, []);
24 |
25 | async function handleSubmit(e) {
26 | if (!email) return;
27 | setLoading(true);
28 | const res = await API.post(`/api/user/reset`, {
29 | email,
30 | token,
31 | });
32 | const { success, message } = res.data;
33 | if (success) {
34 | let password = res.data.data;
35 | await copy(password);
36 | showSuccess(`密码已重置并已复制到剪贴板:${password}`);
37 | } else {
38 | showError(message);
39 | }
40 | setLoading(false);
41 | }
42 |
43 | return (
44 |
45 |
46 |
49 |
71 |
72 |
73 | );
74 | };
75 |
76 | export default PasswordResetConfirm;
77 |
--------------------------------------------------------------------------------
/channel/one-bot.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "message-pusher/model"
9 | "net/http"
10 | "strconv"
11 | "strings"
12 | )
13 |
14 | type oneBotMessageRequest struct {
15 | MessageType string `json:"message_type"`
16 | UserId int64 `json:"user_id"`
17 | GroupId int64 `json:"group_id"`
18 | Message string `json:"message"`
19 | AutoEscape bool `json:"auto_escape"`
20 | }
21 |
22 | type oneBotMessageResponse struct {
23 | Message string `json:"message"`
24 | Status string `json:"status"`
25 | RetCode int `json:"retcode"`
26 | }
27 |
28 | func SendOneBotMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
29 | url := fmt.Sprintf("%s/send_msg", channel_.URL)
30 | req := oneBotMessageRequest{
31 | Message: message.Content,
32 | }
33 | if message.Content == "" {
34 | req.Message = message.Description
35 | }
36 | target := channel_.AccountId
37 | if message.To != "" {
38 | target = message.To
39 | }
40 | parts := strings.Split(target, "_")
41 | var idStr string
42 | var type_ string
43 | if len(parts) == 1 {
44 | type_ = "user"
45 | idStr = parts[0]
46 | } else if len(parts) == 2 {
47 | type_ = parts[0]
48 | idStr = parts[1]
49 | } else {
50 | return errors.New("无效的 OneBot 配置")
51 | }
52 | id, _ := strconv.ParseInt(idStr, 10, 64)
53 | if type_ == "user" {
54 | req.UserId = id
55 | req.MessageType = "private"
56 | } else if type_ == "group" {
57 | req.GroupId = id
58 | req.MessageType = "group"
59 | } else {
60 | return errors.New("无效的 OneBot 配置")
61 | }
62 | reqBody, err := json.Marshal(req)
63 | if err != nil {
64 | return err
65 | }
66 | request, _ := http.NewRequest("POST", url, bytes.NewReader(reqBody))
67 | request.Header.Set("Authorization", "Bearer "+channel_.Secret)
68 | request.Header.Set("Content-Type", "application/json")
69 | resp, err := http.DefaultClient.Do(request)
70 | if err != nil {
71 | return err
72 | }
73 | if resp.StatusCode != 200 {
74 | return errors.New(resp.Status)
75 | }
76 | var res oneBotMessageResponse
77 | err = json.NewDecoder(resp.Body).Decode(&res)
78 | if err != nil {
79 | return err
80 | }
81 | if res.RetCode != 0 {
82 | return errors.New(res.Message)
83 | }
84 | return nil
85 | }
86 |
--------------------------------------------------------------------------------
/web/src/pages/User/AddUser.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Button, Form, Header, Segment } from 'semantic-ui-react';
3 | import { API, showError, showSuccess } from '../../helpers';
4 |
5 | const AddUser = () => {
6 | const originInputs = {
7 | username: '',
8 | display_name: '',
9 | password: '',
10 | };
11 | const [inputs, setInputs] = useState(originInputs);
12 | const { username, display_name, password } = inputs;
13 |
14 | const handleInputChange = (e, { name, value }) => {
15 | setInputs((inputs) => ({ ...inputs, [name]: value }));
16 | };
17 |
18 | const submit = async () => {
19 | if (inputs.username === '' || inputs.password === '') return;
20 | const res = await API.post(`/api/user/`, inputs);
21 | const { success, message } = res.data;
22 | if (success) {
23 | showSuccess('用户账户创建成功!');
24 | setInputs(originInputs);
25 | } else {
26 | showError(message);
27 | }
28 | };
29 |
30 | return (
31 | <>
32 |
33 |
34 |
36 |
45 |
46 |
47 |
55 |
56 |
57 |
67 |
68 |
71 |
72 |
73 | >
74 | );
75 | };
76 |
77 | export default AddUser;
78 |
--------------------------------------------------------------------------------
/channel/tencent-alarm.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha1"
6 | "encoding/base64"
7 | "encoding/json"
8 | "errors"
9 | "math/rand"
10 | "message-pusher/model"
11 | "net/http"
12 | "net/url"
13 | "sort"
14 | "strconv"
15 | "strings"
16 | "time"
17 | )
18 |
19 | type TencentAlarmResponse struct {
20 | Code int `json:"code"`
21 | Message string `json:"message"`
22 | CodeDesc string `json:"codeDesc"`
23 | }
24 |
25 | func SendTencentAlarmMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
26 | secretId := channel_.AppId
27 | secretKey := channel_.Secret
28 | policyId := channel_.AccountId
29 | region := channel_.Other
30 | if message.Description == "" {
31 | message.Description = message.Content
32 | }
33 | params := map[string]string{
34 | "Action": "SendCustomAlarmMsg",
35 | "Region": region,
36 | "Timestamp": strconv.FormatInt(time.Now().Unix(), 10),
37 | "Nonce": strconv.Itoa(rand.Intn(65535)),
38 | "SecretId": secretId,
39 | "policyId": policyId,
40 | "msg": message.Description,
41 | }
42 |
43 | keys := make([]string, 0, len(params))
44 | for key := range params {
45 | keys = append(keys, key)
46 | }
47 | sort.Strings(keys)
48 |
49 | srcStr := "GETmonitor.api.qcloud.com/v2/index.php?"
50 | for _, key := range keys {
51 | srcStr += key + "=" + params[key] + "&"
52 | }
53 | srcStr = srcStr[:len(srcStr)-1]
54 |
55 | h := hmac.New(sha1.New, []byte(secretKey))
56 | h.Write([]byte(srcStr))
57 | signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
58 |
59 | params["Signature"] = signature
60 |
61 | urlStr := "https://monitor.api.qcloud.com/v2/index.php?" + urlEncode(params)
62 |
63 | client := &http.Client{}
64 | req, err := http.NewRequest("GET", urlStr, nil)
65 | if err != nil {
66 | return err
67 | }
68 | resp, err := client.Do(req)
69 | if err != nil {
70 | return err
71 | }
72 | var response TencentAlarmResponse
73 | err = json.NewDecoder(resp.Body).Decode(&response)
74 | if err != nil {
75 | return err
76 | }
77 | if response.Code != 0 {
78 | return errors.New(response.Message)
79 | }
80 | return nil
81 | }
82 |
83 | func urlEncode(params map[string]string) string {
84 | var encodedParams []string
85 | for key, value := range params {
86 | encodedParams = append(encodedParams, url.QueryEscape(key)+"="+url.QueryEscape(value))
87 | }
88 | return strings.Join(encodedParams, "&")
89 | }
90 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module message-pusher
2 |
3 | // +heroku goVersion go1.18
4 | go 1.18
5 |
6 | require (
7 | github.com/gin-contrib/sessions v0.0.5
8 | github.com/gin-contrib/static v0.0.1
9 | github.com/gin-gonic/gin v1.9.1
10 | github.com/go-playground/validator/v10 v10.14.0
11 | github.com/go-redis/redis/v8 v8.11.5
12 | github.com/google/uuid v1.3.0
13 | github.com/gorilla/websocket v1.5.0
14 | github.com/tidwall/gjson v1.14.4
15 | github.com/yuin/goldmark v1.5.3
16 | golang.org/x/crypto v0.21.0
17 | gorm.io/driver/mysql v1.4.3
18 | gorm.io/driver/sqlite v1.4.3
19 | gorm.io/gorm v1.24.0
20 | )
21 |
22 | require (
23 | github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
24 | github.com/bytedance/sonic v1.9.1 // indirect
25 | github.com/cespare/xxhash/v2 v2.1.2 // indirect
26 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
27 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
28 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect
29 | github.com/gin-contrib/sse v0.1.0 // indirect
30 | github.com/go-playground/locales v0.14.1 // indirect
31 | github.com/go-playground/universal-translator v0.18.1 // indirect
32 | github.com/go-sql-driver/mysql v1.6.0 // indirect
33 | github.com/goccy/go-json v0.10.2 // indirect
34 | github.com/gomodule/redigo v2.0.0+incompatible // indirect
35 | github.com/gorilla/context v1.1.1 // indirect
36 | github.com/gorilla/securecookie v1.1.1 // indirect
37 | github.com/gorilla/sessions v1.2.1 // indirect
38 | github.com/jinzhu/inflection v1.0.0 // indirect
39 | github.com/jinzhu/now v1.1.5 // indirect
40 | github.com/json-iterator/go v1.1.12 // indirect
41 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect
42 | github.com/leodido/go-urn v1.2.4 // indirect
43 | github.com/mattn/go-isatty v0.0.19 // indirect
44 | github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
45 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
46 | github.com/modern-go/reflect2 v1.0.2 // indirect
47 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect
48 | github.com/tidwall/match v1.1.1 // indirect
49 | github.com/tidwall/pretty v1.2.1 // indirect
50 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
51 | github.com/ugorji/go/codec v1.2.11 // indirect
52 | golang.org/x/arch v0.3.0 // indirect
53 | golang.org/x/net v0.23.0 // indirect
54 | golang.org/x/sys v0.18.0 // indirect
55 | golang.org/x/text v0.14.0 // indirect
56 | google.golang.org/protobuf v1.33.0 // indirect
57 | gopkg.in/yaml.v3 v3.0.1 // indirect
58 | )
59 |
--------------------------------------------------------------------------------
/channel/ding.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "crypto/hmac"
6 | "crypto/sha256"
7 | "encoding/base64"
8 | "encoding/json"
9 | "errors"
10 | "fmt"
11 | "message-pusher/model"
12 | "net/http"
13 | "net/url"
14 | "strings"
15 | "time"
16 | )
17 |
18 | type dingMessageRequest struct {
19 | MessageType string `json:"msgtype"`
20 | Text struct {
21 | Content string `json:"content"`
22 | } `json:"text"`
23 | Markdown struct {
24 | Title string `json:"title"`
25 | Text string `json:"text"`
26 | } `json:"markdown"`
27 | At struct {
28 | AtUserIds []string `json:"atUserIds"`
29 | IsAtAll bool `json:"isAtAll"`
30 | }
31 | }
32 |
33 | type dingMessageResponse struct {
34 | Code int `json:"errcode"`
35 | Message string `json:"errmsg"`
36 | }
37 |
38 | func SendDingMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
39 | // https://open.dingtalk.com/document/robots/custom-robot-access#title-72m-8ag-pqw
40 | messageRequest := dingMessageRequest{
41 | MessageType: "text",
42 | }
43 | if message.Content == "" {
44 | messageRequest.MessageType = "text"
45 | messageRequest.Text.Content = message.Description
46 | } else {
47 | messageRequest.MessageType = "markdown"
48 | messageRequest.Markdown.Title = message.Title
49 | messageRequest.Markdown.Text = message.Content
50 | }
51 | if message.To != "" {
52 | if message.To == "@all" {
53 | messageRequest.At.IsAtAll = true
54 | } else {
55 | messageRequest.At.AtUserIds = strings.Split(message.To, "|")
56 | }
57 | }
58 |
59 | timestamp := time.Now().UnixMilli()
60 | sign, err := dingSign(channel_.Secret, timestamp)
61 | if err != nil {
62 | return err
63 | }
64 | jsonData, err := json.Marshal(messageRequest)
65 | if err != nil {
66 | return err
67 | }
68 | resp, err := http.Post(fmt.Sprintf("%s×tamp=%d&sign=%s", channel_.URL, timestamp, sign), "application/json",
69 | bytes.NewBuffer(jsonData))
70 | if err != nil {
71 | return err
72 | }
73 | var res dingMessageResponse
74 | err = json.NewDecoder(resp.Body).Decode(&res)
75 | if err != nil {
76 | return err
77 | }
78 | if res.Code != 0 {
79 | return errors.New(res.Message)
80 | }
81 | return nil
82 | }
83 |
84 | func dingSign(secret string, timestamp int64) (string, error) {
85 | // https://open.dingtalk.com/document/robots/customize-robot-security-settings
86 | // timestamp + key -> sha256 -> URL encode
87 | stringToSign := fmt.Sprintf("%d\n%s", timestamp, secret)
88 | h := hmac.New(sha256.New, []byte(secret))
89 | _, err := h.Write([]byte(stringToSign))
90 | if err != nil {
91 | return "", err
92 | }
93 | signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
94 | signature = url.QueryEscape(signature)
95 | return signature, nil
96 | }
97 |
--------------------------------------------------------------------------------
/channel/telegram.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "message-pusher/model"
9 | "net/http"
10 | "unicode/utf8"
11 | )
12 |
13 | var TelegramMaxMessageLength = 4096
14 |
15 | type telegramMessageRequest struct {
16 | ChatId string `json:"chat_id"`
17 | Text string `json:"text"`
18 | ParseMode string `json:"parse_mode"`
19 | }
20 |
21 | type telegramMessageResponse struct {
22 | Ok bool `json:"ok"`
23 | Description string `json:"description"`
24 | }
25 |
26 | func SendTelegramMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
27 | // https://core.telegram.org/bots/api#sendmessage
28 | messageRequest := telegramMessageRequest{
29 | ChatId: channel_.AccountId,
30 | }
31 | if message.To != "" {
32 | messageRequest.ChatId = message.To
33 | }
34 | if message.Content == "" {
35 | messageRequest.Text = message.Description
36 | } else {
37 | messageRequest.Text = message.Content
38 | messageRequest.ParseMode = "markdown"
39 | }
40 | text := messageRequest.Text
41 | idx := 0
42 | for idx < len(text) {
43 | nextIdx := idx + TelegramMaxMessageLength
44 | if nextIdx > len(text) {
45 | // we have reach the end, must be valid
46 | nextIdx = len(text)
47 | } else {
48 | nextIdx = getNearestValidSplit(text, nextIdx, messageRequest.ParseMode)
49 | }
50 | messageRequest.Text = text[idx:nextIdx]
51 | idx = nextIdx
52 | jsonData, err := json.Marshal(messageRequest)
53 | if err != nil {
54 | return err
55 | }
56 | resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", channel_.Secret), "application/json",
57 | bytes.NewBuffer(jsonData))
58 | if err != nil {
59 | return err
60 | }
61 | var res telegramMessageResponse
62 | err = json.NewDecoder(resp.Body).Decode(&res)
63 | if err != nil {
64 | return err
65 | }
66 | if !res.Ok {
67 | return errors.New(res.Description)
68 | }
69 | }
70 | return nil
71 | }
72 |
73 | func getNearestValidSplit(s string, idx int, mode string) int {
74 | if mode == "markdown" {
75 | return getMarkdownNearestValidSplit(s, idx)
76 | } else {
77 | return getPlainTextNearestValidSplit(s, idx)
78 | }
79 | }
80 |
81 | func getPlainTextNearestValidSplit(s string, idx int) int {
82 | if idx >= len(s) {
83 | return idx
84 | }
85 | if idx == 0 {
86 | return 0
87 | }
88 | isStartByte := utf8.RuneStart(s[idx])
89 | if isStartByte {
90 | return idx
91 | } else {
92 | return getPlainTextNearestValidSplit(s, idx-1)
93 | }
94 | }
95 |
96 | func getMarkdownNearestValidSplit(s string, idx int) int {
97 | if idx >= len(s) {
98 | return idx
99 | }
100 | if idx == 0 {
101 | return 0
102 | }
103 | for i := idx; i >= 0; i-- {
104 | if s[i] == '\n' {
105 | return i + 1
106 | }
107 | }
108 | // unable to find a '\n'
109 | return idx
110 | }
111 |
--------------------------------------------------------------------------------
/web/src/components/PasswordResetForm.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
3 | import { API, showError, showInfo, showSuccess } from '../helpers';
4 | import Turnstile from 'react-turnstile';
5 |
6 | const PasswordResetForm = () => {
7 | const [inputs, setInputs] = useState({
8 | email: '',
9 | });
10 | const { email } = inputs;
11 |
12 | const [loading, setLoading] = useState(false);
13 | const [turnstileEnabled, setTurnstileEnabled] = useState(false);
14 | const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
15 | const [turnstileToken, setTurnstileToken] = useState('');
16 |
17 | useEffect(() => {
18 | let status = localStorage.getItem('status');
19 | if (status) {
20 | status = JSON.parse(status);
21 | if (status.turnstile_check) {
22 | setTurnstileEnabled(true);
23 | setTurnstileSiteKey(status.turnstile_site_key);
24 | }
25 | }
26 | }, []);
27 |
28 | function handleChange(e) {
29 | const { name, value } = e.target;
30 | setInputs((inputs) => ({ ...inputs, [name]: value }));
31 | }
32 |
33 | async function handleSubmit(e) {
34 | if (!email) return;
35 | if (turnstileEnabled && turnstileToken === '') {
36 | showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
37 | return;
38 | }
39 | setLoading(true);
40 | const res = await API.get(
41 | `/api/reset_password?email=${email}&turnstile=${turnstileToken}`
42 | );
43 | const { success, message } = res.data;
44 | if (success) {
45 | showSuccess('重置邮件发送成功,请检查邮箱!');
46 | setInputs({ ...inputs, email: '' });
47 | } else {
48 | showError(message);
49 | }
50 | setLoading(false);
51 | }
52 |
53 | return (
54 |
55 |
56 |
59 |
91 |
92 |
93 | );
94 | };
95 |
96 | export default PasswordResetForm;
97 |
--------------------------------------------------------------------------------
/model/webhook.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "errors"
5 | )
6 |
7 | // WebhookConstructRule Keep compatible with Message
8 | type WebhookConstructRule struct {
9 | Title string `json:"title"`
10 | Description string `json:"description"`
11 | Content string `json:"content"`
12 | URL string `json:"url"`
13 | }
14 |
15 | type Webhook struct {
16 | Id int `json:"id"`
17 | UserId int `json:"user_id" gorm:"index"`
18 | Name string `json:"name" gorm:"type:varchar(32);index"`
19 | Status int `json:"status" gorm:"default:1"` // enabled, disabled
20 | Link string `json:"link" gorm:"type:char(32);uniqueIndex"`
21 | CreatedTime int64 `json:"created_time" gorm:"bigint"`
22 | ExtractRule string `json:"extract_rule" gorm:"not null"` // how we extract key info from the request
23 | ConstructRule string `json:"construct_rule" gorm:"not null"` // how we construct message with the extracted info
24 | Channel string `json:"channel" gorm:"type:varchar(32); not null"` // which channel to send our message
25 | }
26 |
27 | func GetWebhookById(id int, userId int) (*Webhook, error) {
28 | if id == 0 || userId == 0 {
29 | return nil, errors.New("id 或 userId 为空!")
30 | }
31 | c := Webhook{Id: id, UserId: userId}
32 | err := DB.Where(c).First(&c).Error
33 | return &c, err
34 | }
35 |
36 | func GetWebhookByLink(link string) (*Webhook, error) {
37 | if link == "" {
38 | return nil, errors.New("link 为空!")
39 | }
40 | c := Webhook{Link: link}
41 | err := DB.Where(c).First(&c).Error
42 | return &c, err
43 | }
44 |
45 | func GetWebhooksByUserId(userId int, startIdx int, num int) (webhooks []*Webhook, err error) {
46 | err = DB.Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&webhooks).Error
47 | return webhooks, err
48 | }
49 |
50 | func SearchWebhooks(userId int, keyword string) (webhooks []*Webhook, err error) {
51 | err = DB.Where("user_id = ?", userId).Where("id = ? or link = ? or name LIKE ?", keyword, keyword, keyword+"%").Find(&webhooks).Error
52 | return webhooks, err
53 | }
54 |
55 | func DeleteWebhookById(id int, userId int) (c *Webhook, err error) {
56 | // Why we need userId here? In case user want to delete other's c.
57 | if id == 0 || userId == 0 {
58 | return nil, errors.New("id 或 userId 为空!")
59 | }
60 | c = &Webhook{Id: id, UserId: userId}
61 | err = DB.Where(c).First(&c).Error
62 | if err != nil {
63 | return nil, err
64 | }
65 | return c, c.Delete()
66 | }
67 |
68 | func (webhook *Webhook) Insert() error {
69 | var err error
70 | err = DB.Create(webhook).Error
71 | return err
72 | }
73 |
74 | func (webhook *Webhook) UpdateStatus(status int) error {
75 | err := DB.Model(webhook).Update("status", status).Error
76 | return err
77 | }
78 |
79 | // Update Make sure your token's fields is completed, because this will update zero values
80 | func (webhook *Webhook) Update() error {
81 | var err error
82 | err = DB.Model(webhook).Select("status", "name", "extract_rule", "construct_rule", "channel").Updates(webhook).Error
83 | return err
84 | }
85 |
86 | func (webhook *Webhook) Delete() error {
87 | err := DB.Delete(webhook).Error
88 | return err
89 | }
90 |
--------------------------------------------------------------------------------
/bin/migrate_v3_to_v4.sql:
--------------------------------------------------------------------------------
1 | -- 未测试,仅供参考
2 |
3 | -- 插入 email 通道数据
4 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
5 | SELECT 'email', id, 'email', '', 1, '', '', '', '', '', UNIX_TIMESTAMP()
6 | FROM old_database.users
7 | WHERE email IS NOT NULL;
8 |
9 | -- 插入 test 通道数据
10 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
11 | SELECT 'test', id, 'test', '', 1, wechat_test_account_secret, wechat_test_account_id, wechat_test_account_open_id, '', wechat_test_account_template_id, UNIX_TIMESTAMP()
12 | FROM old_database.users
13 | WHERE wechat_test_account_id IS NOT NULL;
14 |
15 | -- 插入 corp_app 通道数据
16 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
17 | SELECT 'corp_app', id, 'corp_app', '', 1, wechat_corp_account_agent_secret, CONCAT(wechat_corp_account_id, '|', wechat_corp_account_agent_id), wechat_corp_account_user_id, '', wechat_corp_account_client_type, UNIX_TIMESTAMP()
18 | FROM old_database.users
19 | WHERE wechat_corp_account_id IS NOT NULL;
20 |
21 | -- 插入 lark 通道数据
22 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
23 | SELECT 'lark', id, 'lark', '', 1, lark_webhook_secret, '', '', lark_webhook_url, '', UNIX_TIMESTAMP()
24 | FROM old_database.users
25 | WHERE lark_webhook_url IS NOT NULL;
26 |
27 | -- 插入 ding 通道数据
28 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
29 | SELECT 'ding', id, 'ding', '', 1, ding_webhook_secret, '', '', ding_webhook_url, '', UNIX_TIMESTAMP()
30 | FROM old_database.users
31 | WHERE ding_webhook_url IS NOT NULL;
32 |
33 | -- 插入 corp 通道数据
34 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
35 | SELECT 'corp', id, 'corp', '', 1, '', '', '', corp_webhook_url, '', UNIX_TIMESTAMP()
36 | FROM old_database.users
37 | WHERE corp_webhook_url IS NOT NULL;
38 |
39 | -- 插入 bark 通道数据
40 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
41 | SELECT 'bark', id, 'bark', '', 1, bark_secret, '', '', bark_server, '', UNIX_TIMESTAMP()
42 | FROM old_database.users
43 | WHERE bark_server IS NOT NULL;
44 |
45 | -- 插入 telegram 通道数据
46 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
47 | SELECT 'telegram', id, 'telegram', '', 1, telegram_bot_token, '', telegram_chat_id, '', '', UNIX_TIMESTAMP()
48 | FROM old_database.users
49 | WHERE telegram_bot_token IS NOT NULL;
50 |
51 | -- 插入 discord 通道数据
52 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
53 | SELECT 'discord', id, 'discord', '', 1, '', '', '', discord_webhook_url, '', UNIX_TIMESTAMP()
54 | FROM old_database.users
55 | WHERE discord_webhook_url IS NOT NULL;
56 |
57 | -- 插入 client 通道数据
58 | INSERT INTO channels (type, user_id, name, description, status, secret, app_id, account_id, url, other, created_time)
59 | SELECT 'client', id, 'client', '', 1, client_secret, '', '', '', '', UNIX_TIMESTAMP()
60 | FROM old_database.users
61 | WHERE client_secret IS NOT NULL;
62 |
63 |
--------------------------------------------------------------------------------
/common/constants.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "github.com/google/uuid"
5 | "sync"
6 | "time"
7 | )
8 |
9 | var StartTime = time.Now().Unix() // unit: second
10 | var Version = "v0.0.0"
11 | var SystemName = "消息推送服务"
12 | var ServerAddress = "http://localhost:3000"
13 | var Footer = ""
14 | var HomePageLink = ""
15 | var MessageCount = 0 // Non critical value, no need to use atomic
16 | var UserCount = 0 // Non critical value, no need to use atomic
17 |
18 | // Any options with "Secret", "Token" in its key won't be return by GetOptions
19 |
20 | var SessionSecret = uuid.New().String()
21 | var SQLitePath = "message-pusher.db"
22 |
23 | var OptionMap map[string]string
24 | var OptionMapRWMutex sync.RWMutex
25 |
26 | var ItemsPerPage = 10
27 |
28 | var PasswordLoginEnabled = true
29 | var PasswordRegisterEnabled = true
30 | var EmailVerificationEnabled = false
31 | var GitHubOAuthEnabled = false
32 | var WeChatAuthEnabled = false
33 | var TurnstileCheckEnabled = false
34 | var RegisterEnabled = true
35 | var MessagePersistenceEnabled = true
36 | var MessageRenderEnabled = true
37 |
38 | var SMTPServer = ""
39 | var SMTPPort = 587
40 | var SMTPAccount = ""
41 | var SMTPToken = ""
42 |
43 | var GitHubClientId = ""
44 | var GitHubClientSecret = ""
45 |
46 | var WeChatServerAddress = ""
47 | var WeChatServerToken = ""
48 | var WeChatAccountQRCodeImageURL = ""
49 |
50 | var TurnstileSiteKey = ""
51 | var TurnstileSecretKey = ""
52 |
53 | const (
54 | RoleGuestUser = 0
55 | RoleCommonUser = 1
56 | RoleAdminUser = 10
57 | RoleRootUser = 100
58 | )
59 |
60 | var (
61 | FileUploadPermission = RoleGuestUser
62 | FileDownloadPermission = RoleGuestUser
63 | ImageUploadPermission = RoleGuestUser
64 | ImageDownloadPermission = RoleGuestUser
65 | )
66 |
67 | // All duration's unit is seconds
68 | // Shouldn't larger then RateLimitKeyExpirationDuration
69 | var (
70 | GlobalApiRateLimitNum = 60
71 | GlobalApiRateLimitDuration int64 = 3 * 60
72 |
73 | GlobalWebRateLimitNum = 60
74 | GlobalWebRateLimitDuration int64 = 3 * 60
75 |
76 | UploadRateLimitNum = 10
77 | UploadRateLimitDuration int64 = 60
78 |
79 | DownloadRateLimitNum = 10
80 | DownloadRateLimitDuration int64 = 60
81 |
82 | CriticalRateLimitNum = 20
83 | CriticalRateLimitDuration int64 = 20 * 60
84 | )
85 |
86 | var RateLimitKeyExpirationDuration = 20 * time.Minute
87 |
88 | const (
89 | UserStatusNonExisted = 0
90 | UserStatusEnabled = 1 // don't use 0, 0 is the default value!
91 | UserStatusDisabled = 2 // also don't use 0
92 | )
93 |
94 | const (
95 | SendEmailToOthersAllowed = 1
96 | SendEmailToOthersDisallowed = 2
97 | )
98 |
99 | const (
100 | SaveMessageToDatabaseAllowed = 1
101 | SaveMessageToDatabaseDisallowed = 2
102 | )
103 |
104 | const (
105 | MessageSendStatusUnknown = 0
106 | MessageSendStatusPending = 1
107 | MessageSendStatusSent = 2
108 | MessageSendStatusFailed = 3
109 | MessageSendStatusAsyncPending = 4
110 | )
111 |
112 | const (
113 | ChannelStatusUnknown = 0
114 | ChannelStatusEnabled = 1
115 | ChannelStatusDisabled = 2
116 | )
117 |
118 | const (
119 | WebhookStatusUnknown = 0
120 | WebhookStatusEnabled = 1
121 | WebhookStatusDisabled = 2
122 | )
123 |
--------------------------------------------------------------------------------
/web/src/components/PushSetting.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Grid, Header, Message } from 'semantic-ui-react';
3 | import {
4 | API,
5 | generateToken,
6 | showError,
7 | showSuccess,
8 | testChannel,
9 | } from '../helpers';
10 | import { loadUser, loadUserChannels } from '../helpers/loader';
11 |
12 | const PushSetting = () => {
13 | let [user, setUser] = useState({
14 | id: '',
15 | username: '',
16 | channel: '',
17 | token: '',
18 | });
19 | let [channels, setChannels] = useState([]);
20 | let [loading, setLoading] = useState(true);
21 |
22 | const handleInputChange = (e, { name, value }) => {
23 | setUser((inputs) => ({ ...inputs, [name]: value }));
24 | };
25 |
26 | useEffect(() => {
27 | const loader = async () => {
28 | let user = await loadUser();
29 | if (user) {
30 | setUser(user);
31 | }
32 | let channels = await loadUserChannels();
33 | if (channels) {
34 | setChannels(channels);
35 | }
36 | setLoading(false);
37 | };
38 | loader().then();
39 | }, []);
40 |
41 | const submit = async (which) => {
42 | let data = {};
43 | switch (which) {
44 | case 'general':
45 | data.channel = user.channel;
46 | data.token = user.token;
47 | if (data.token === '') {
48 | data.token = ' ';
49 | }
50 | break;
51 | default:
52 | showError(`无效的参数:${which}`);
53 | return;
54 | }
55 | let res = await API.put(`/api/user/self`, data);
56 | const { success, message } = res.data;
57 | if (success) {
58 | showSuccess('设置已更新!');
59 | } else {
60 | showError(message);
61 | }
62 | };
63 |
64 | return (
65 |
66 |
67 |
73 |
81 | {
91 | console.log('generate token');
92 | setUser((inputs) => ({
93 | ...inputs,
94 | token: generateToken(16),
95 | }));
96 | },
97 | }}
98 | />
99 |
100 |
103 |
106 |
107 |
108 |
109 | );
110 | };
111 |
112 | export default PushSetting;
113 |
--------------------------------------------------------------------------------
/middleware/rate-limit.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "github.com/gin-gonic/gin"
7 | "message-pusher/common"
8 | "net/http"
9 | "time"
10 | )
11 |
12 | var timeFormat = "2006-01-02T15:04:05.000Z"
13 |
14 | var inMemoryRateLimiter common.InMemoryRateLimiter
15 |
16 | func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
17 | ctx := context.Background()
18 | rdb := common.RDB
19 | key := "rateLimit:" + mark + c.ClientIP()
20 | listLength, err := rdb.LLen(ctx, key).Result()
21 | if err != nil {
22 | fmt.Println(err.Error())
23 | c.Status(http.StatusInternalServerError)
24 | c.Abort()
25 | return
26 | }
27 | if listLength < int64(maxRequestNum) {
28 | rdb.LPush(ctx, key, time.Now().Format(timeFormat))
29 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
30 | } else {
31 | oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()
32 | oldTime, err := time.Parse(timeFormat, oldTimeStr)
33 | if err != nil {
34 | fmt.Println(err)
35 | c.Status(http.StatusInternalServerError)
36 | c.Abort()
37 | return
38 | }
39 | nowTimeStr := time.Now().Format(timeFormat)
40 | nowTime, err := time.Parse(timeFormat, nowTimeStr)
41 | if err != nil {
42 | fmt.Println(err)
43 | c.Status(http.StatusInternalServerError)
44 | c.Abort()
45 | return
46 | }
47 | // time.Since will return negative number!
48 | // See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows
49 | if int64(nowTime.Sub(oldTime).Seconds()) < duration {
50 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
51 | c.Status(http.StatusTooManyRequests)
52 | c.Abort()
53 | return
54 | } else {
55 | rdb.LPush(ctx, key, time.Now().Format(timeFormat))
56 | rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
57 | rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
58 | }
59 | }
60 | }
61 |
62 | func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
63 | key := mark + c.ClientIP()
64 | if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) {
65 | c.Status(http.StatusTooManyRequests)
66 | c.Abort()
67 | return
68 | }
69 | }
70 |
71 | func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {
72 | if common.RedisEnabled {
73 | return func(c *gin.Context) {
74 | redisRateLimiter(c, maxRequestNum, duration, mark)
75 | }
76 | } else {
77 | // It's safe to call multi times.
78 | inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
79 | return func(c *gin.Context) {
80 | memoryRateLimiter(c, maxRequestNum, duration, mark)
81 | }
82 | }
83 | }
84 |
85 | func GlobalWebRateLimit() func(c *gin.Context) {
86 | return rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW")
87 | }
88 |
89 | func GlobalAPIRateLimit() func(c *gin.Context) {
90 | return rateLimitFactory(common.GlobalApiRateLimitNum, common.GlobalApiRateLimitDuration, "GA")
91 | }
92 |
93 | func CriticalRateLimit() func(c *gin.Context) {
94 | return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
95 | }
96 |
97 | func DownloadRateLimit() func(c *gin.Context) {
98 | return rateLimitFactory(common.DownloadRateLimitNum, common.DownloadRateLimitDuration, "DW")
99 | }
100 |
101 | func UploadRateLimit() func(c *gin.Context) {
102 | return rateLimitFactory(common.UploadRateLimitNum, common.UploadRateLimitDuration, "UP")
103 | }
104 |
--------------------------------------------------------------------------------
/channel/lark.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "crypto/hmac"
6 | "crypto/sha256"
7 | "encoding/base64"
8 | "encoding/json"
9 | "errors"
10 | "fmt"
11 | "message-pusher/model"
12 | "net/http"
13 | "strconv"
14 | "strings"
15 | "time"
16 | )
17 |
18 | type larkMessageRequestCardElementText struct {
19 | Content string `json:"content"`
20 | Tag string `json:"tag"`
21 | }
22 |
23 | type larkMessageRequestCardElement struct {
24 | Tag string `json:"tag"`
25 | Text larkMessageRequestCardElementText `json:"text"`
26 | }
27 |
28 | type larkTextContent struct {
29 | Text string `json:"text"`
30 | }
31 |
32 | type larkCardContent struct {
33 | Config struct {
34 | WideScreenMode bool `json:"wide_screen_mode"`
35 | EnableForward bool `json:"enable_forward"`
36 | }
37 | Elements []larkMessageRequestCardElement `json:"elements"`
38 | }
39 |
40 | type larkMessageRequest struct {
41 | MessageType string `json:"msg_type"`
42 | Timestamp string `json:"timestamp"`
43 | Sign string `json:"sign"`
44 | Content larkTextContent `json:"content"`
45 | Card larkCardContent `json:"card"`
46 | }
47 |
48 | type larkMessageResponse struct {
49 | Code int `json:"code"`
50 | Message string `json:"msg"`
51 | }
52 |
53 | func getLarkAtPrefix(message *model.Message) string {
54 | atPrefix := ""
55 | if message.To != "" {
56 | if message.To == "@all" {
57 | atPrefix = "所有人"
58 | } else {
59 | ids := strings.Split(message.To, "|")
60 | for _, id := range ids {
61 | atPrefix += fmt.Sprintf(" ", id)
62 | }
63 | }
64 | }
65 | return atPrefix
66 | }
67 |
68 | func SendLarkMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
69 | // https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN#e1cdee9f
70 | messageRequest := larkMessageRequest{
71 | MessageType: "text",
72 | }
73 | atPrefix := getLarkAtPrefix(message)
74 | if message.Content == "" {
75 | messageRequest.MessageType = "text"
76 | messageRequest.Content.Text = atPrefix + message.Description
77 | } else {
78 | messageRequest.MessageType = "interactive"
79 | messageRequest.Card.Config.WideScreenMode = true
80 | messageRequest.Card.Config.EnableForward = true
81 | messageRequest.Card.Elements = append(messageRequest.Card.Elements, larkMessageRequestCardElement{
82 | Tag: "div",
83 | Text: larkMessageRequestCardElementText{
84 | Content: atPrefix + message.Content,
85 | Tag: "lark_md",
86 | },
87 | })
88 | }
89 |
90 | now := time.Now()
91 | timestamp := now.Unix()
92 | sign, err := larkSign(channel_.Secret, timestamp)
93 | if err != nil {
94 | return err
95 | }
96 | messageRequest.Sign = sign
97 | messageRequest.Timestamp = strconv.FormatInt(timestamp, 10)
98 | jsonData, err := json.Marshal(messageRequest)
99 | if err != nil {
100 | return err
101 | }
102 | resp, err := http.Post(channel_.URL, "application/json",
103 | bytes.NewBuffer(jsonData))
104 | if err != nil {
105 | return err
106 | }
107 | var res larkMessageResponse
108 | err = json.NewDecoder(resp.Body).Decode(&res)
109 | if err != nil {
110 | return err
111 | }
112 | if res.Code != 0 {
113 | return errors.New(res.Message)
114 | }
115 | return nil
116 | }
117 |
118 | func larkSign(secret string, timestamp int64) (string, error) {
119 | // https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN?lang=zh-CN
120 | // timestamp + key -> sha256 -> base64 encode
121 | stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret
122 | var data []byte
123 | h := hmac.New(sha256.New, []byte(stringToSign))
124 | _, err := h.Write(data)
125 | if err != nil {
126 | return "", err
127 | }
128 | signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
129 | return signature, nil
130 | }
131 |
--------------------------------------------------------------------------------
/controller/wechat.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "github.com/gin-gonic/gin"
8 | "message-pusher/common"
9 | "message-pusher/model"
10 | "net/http"
11 | "strconv"
12 | "time"
13 | )
14 |
15 | type wechatLoginResponse struct {
16 | Success bool `json:"success"`
17 | Message string `json:"message"`
18 | Data string `json:"data"`
19 | }
20 |
21 | func getWeChatIdByCode(code string) (string, error) {
22 | if code == "" {
23 | return "", errors.New("无效的参数")
24 | }
25 | req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil)
26 | if err != nil {
27 | return "", err
28 | }
29 | req.Header.Set("Authorization", common.WeChatServerToken)
30 | client := http.Client{
31 | Timeout: 5 * time.Second,
32 | }
33 | httpResponse, err := client.Do(req)
34 | if err != nil {
35 | return "", err
36 | }
37 | defer httpResponse.Body.Close()
38 | var res wechatLoginResponse
39 | err = json.NewDecoder(httpResponse.Body).Decode(&res)
40 | if err != nil {
41 | return "", err
42 | }
43 | if !res.Success {
44 | return "", errors.New(res.Message)
45 | }
46 | if res.Data == "" {
47 | return "", errors.New("验证码错误或已过期")
48 | }
49 | return res.Data, nil
50 | }
51 |
52 | func WeChatAuth(c *gin.Context) {
53 | if !common.WeChatAuthEnabled {
54 | c.JSON(http.StatusOK, gin.H{
55 | "message": "管理员未开启通过微信登录以及注册",
56 | "success": false,
57 | })
58 | return
59 | }
60 | code := c.Query("code")
61 | wechatId, err := getWeChatIdByCode(code)
62 | if err != nil {
63 | c.JSON(http.StatusOK, gin.H{
64 | "message": err.Error(),
65 | "success": false,
66 | })
67 | return
68 | }
69 | user := model.User{
70 | WeChatId: wechatId,
71 | }
72 | if model.IsWeChatIdAlreadyTaken(wechatId) {
73 | err := user.FillUserByWeChatId()
74 | if err != nil {
75 | c.JSON(http.StatusOK, gin.H{
76 | "success": false,
77 | "message": err.Error(),
78 | })
79 | return
80 | }
81 | } else {
82 | if common.RegisterEnabled {
83 | user.Username = "wechat_" + strconv.Itoa(model.GetMaxUserId()+1)
84 | user.DisplayName = "WeChat User"
85 | user.Role = common.RoleCommonUser
86 | user.Status = common.UserStatusEnabled
87 |
88 | if err := user.Insert(); err != nil {
89 | c.JSON(http.StatusOK, gin.H{
90 | "success": false,
91 | "message": err.Error(),
92 | })
93 | return
94 | }
95 | } else {
96 | c.JSON(http.StatusOK, gin.H{
97 | "success": false,
98 | "message": "管理员关闭了新用户注册",
99 | })
100 | return
101 | }
102 | }
103 |
104 | if user.Status != common.UserStatusEnabled {
105 | c.JSON(http.StatusOK, gin.H{
106 | "message": "用户已被封禁",
107 | "success": false,
108 | })
109 | return
110 | }
111 | setupLogin(&user, c)
112 | }
113 |
114 | func WeChatBind(c *gin.Context) {
115 | if !common.WeChatAuthEnabled {
116 | c.JSON(http.StatusOK, gin.H{
117 | "message": "管理员未开启通过微信登录以及注册",
118 | "success": false,
119 | })
120 | return
121 | }
122 | code := c.Query("code")
123 | wechatId, err := getWeChatIdByCode(code)
124 | if err != nil {
125 | c.JSON(http.StatusOK, gin.H{
126 | "message": err.Error(),
127 | "success": false,
128 | })
129 | return
130 | }
131 | if model.IsWeChatIdAlreadyTaken(wechatId) {
132 | c.JSON(http.StatusOK, gin.H{
133 | "success": false,
134 | "message": "该微信账号已被绑定",
135 | })
136 | return
137 | }
138 | id := c.GetInt("id")
139 | user := model.User{
140 | Id: id,
141 | }
142 | err = user.FillUserById()
143 | if err != nil {
144 | c.JSON(http.StatusOK, gin.H{
145 | "success": false,
146 | "message": err.Error(),
147 | })
148 | return
149 | }
150 | user.WeChatId = wechatId
151 | err = user.Update(false)
152 | if err != nil {
153 | c.JSON(http.StatusOK, gin.H{
154 | "success": false,
155 | "message": err.Error(),
156 | })
157 | return
158 | }
159 | c.JSON(http.StatusOK, gin.H{
160 | "success": true,
161 | "message": "",
162 | })
163 | return
164 | }
165 |
--------------------------------------------------------------------------------
/common/utils.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "github.com/google/uuid"
7 | "github.com/yuin/goldmark"
8 | "github.com/yuin/goldmark/extension"
9 | "html/template"
10 | "log"
11 | "net"
12 | "os/exec"
13 | "runtime"
14 | "strconv"
15 | "strings"
16 | "time"
17 | )
18 |
19 | func OpenBrowser(url string) {
20 | var err error
21 |
22 | switch runtime.GOOS {
23 | case "linux":
24 | err = exec.Command("xdg-open", url).Start()
25 | case "windows":
26 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
27 | case "darwin":
28 | err = exec.Command("open", url).Start()
29 | }
30 | if err != nil {
31 | log.Println(err)
32 | }
33 | }
34 |
35 | func GetIp() (ip string) {
36 | ips, err := net.InterfaceAddrs()
37 | if err != nil {
38 | log.Println(err)
39 | return ip
40 | }
41 |
42 | for _, a := range ips {
43 | if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
44 | if ipNet.IP.To4() != nil {
45 | ip = ipNet.IP.String()
46 | if strings.HasPrefix(ip, "10") {
47 | return
48 | }
49 | if strings.HasPrefix(ip, "172") {
50 | return
51 | }
52 | if strings.HasPrefix(ip, "192.168") {
53 | return
54 | }
55 | ip = ""
56 | }
57 | }
58 | }
59 | return
60 | }
61 |
62 | var sizeKB = 1024
63 | var sizeMB = sizeKB * 1024
64 | var sizeGB = sizeMB * 1024
65 |
66 | func Bytes2Size(num int64) string {
67 | numStr := ""
68 | unit := "B"
69 | if num/int64(sizeGB) > 1 {
70 | numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
71 | unit = "GB"
72 | } else if num/int64(sizeMB) > 1 {
73 | numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB)))
74 | unit = "MB"
75 | } else if num/int64(sizeKB) > 1 {
76 | numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB)))
77 | unit = "KB"
78 | } else {
79 | numStr = fmt.Sprintf("%d", num)
80 | }
81 | return numStr + " " + unit
82 | }
83 |
84 | func Seconds2Time(num int) (time string) {
85 | if num/31104000 > 0 {
86 | time += strconv.Itoa(num/31104000) + " 年 "
87 | num %= 31104000
88 | }
89 | if num/2592000 > 0 {
90 | time += strconv.Itoa(num/2592000) + " 个月 "
91 | num %= 2592000
92 | }
93 | if num/86400 > 0 {
94 | time += strconv.Itoa(num/86400) + " 天 "
95 | num %= 86400
96 | }
97 | if num/3600 > 0 {
98 | time += strconv.Itoa(num/3600) + " 小时 "
99 | num %= 3600
100 | }
101 | if num/60 > 0 {
102 | time += strconv.Itoa(num/60) + " 分钟 "
103 | num %= 60
104 | }
105 | time += strconv.Itoa(num) + " 秒"
106 | return
107 | }
108 |
109 | func Interface2String(inter interface{}) string {
110 | switch inter.(type) {
111 | case string:
112 | return inter.(string)
113 | case int:
114 | return fmt.Sprintf("%d", inter.(int))
115 | case float64:
116 | return fmt.Sprintf("%f", inter.(float64))
117 | }
118 | return "Not Implemented"
119 | }
120 |
121 | func UnescapeHTML(x string) interface{} {
122 | return template.HTML(x)
123 | }
124 |
125 | func IntMax(a int, b int) int {
126 | if a >= b {
127 | return a
128 | } else {
129 | return b
130 | }
131 | }
132 |
133 | func GetUUID() string {
134 | code := uuid.New().String()
135 | code = strings.Replace(code, "-", "", -1)
136 | return code
137 | }
138 |
139 | func Max(a int, b int) int {
140 | if a >= b {
141 | return a
142 | } else {
143 | return b
144 | }
145 | }
146 |
147 | func Markdown2HTML(markdown string) (HTML string, err error) {
148 | if markdown == "" {
149 | return "", nil
150 | }
151 | var buf bytes.Buffer
152 | goldMarkEntity := goldmark.New(
153 | goldmark.WithExtensions(
154 | extension.GFM,
155 | extension.Footnote,
156 | ),
157 | )
158 | err = goldMarkEntity.Convert([]byte(markdown), &buf)
159 | if err != nil {
160 | return fmt.Sprintf("Markdown 渲染出错:%s", err.Error()), err
161 | }
162 | HTML = buf.String()
163 | return
164 | }
165 |
166 | func GetTimestamp() int64 {
167 | return time.Now().Unix()
168 | }
169 |
170 | func Replace(s, old, new string, n int) string {
171 | new = strings.TrimPrefix(strings.TrimSuffix(fmt.Sprintf("%q", new), "\""), "\"")
172 | return strings.Replace(s, old, new, n)
173 | }
174 |
--------------------------------------------------------------------------------
/web/src/pages/User/EditUser.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Header, Segment } from 'semantic-ui-react';
3 | import { useParams } from 'react-router-dom';
4 | import { API, showError, showSuccess } from '../../helpers';
5 |
6 | const EditUser = () => {
7 | const params = useParams();
8 | const userId = params.id;
9 | const [loading, setLoading] = useState(true);
10 | const [inputs, setInputs] = useState({
11 | username: '',
12 | display_name: '',
13 | password: '',
14 | github_id: '',
15 | wechat_id: '',
16 | email: '',
17 | });
18 | const { username, display_name, password, github_id, wechat_id, email } =
19 | inputs;
20 | const handleInputChange = (e, { name, value }) => {
21 | setInputs((inputs) => ({ ...inputs, [name]: value }));
22 | };
23 |
24 | const loadUser = async () => {
25 | let res = undefined;
26 | if (userId) {
27 | res = await API.get(`/api/user/${userId}`);
28 | } else {
29 | res = await API.get(`/api/user/self`);
30 | }
31 | const { success, message, data } = res.data;
32 | if (success) {
33 | data.password = '';
34 | setInputs(data);
35 | } else {
36 | showError(message);
37 | }
38 | setLoading(false);
39 | };
40 | useEffect(() => {
41 | loadUser().then();
42 | }, []);
43 |
44 | const submit = async () => {
45 | let res = undefined;
46 | if (userId) {
47 | res = await API.put(`/api/user/`, { ...inputs, id: parseInt(userId) });
48 | } else {
49 | res = await API.put(`/api/user/self`, inputs);
50 | }
51 | const { success, message } = res.data;
52 | if (success) {
53 | showSuccess('用户信息更新成功!');
54 | } else {
55 | showError(message);
56 | }
57 | };
58 |
59 | return (
60 | <>
61 |
62 |
63 |
65 |
73 |
74 |
75 |
84 |
85 |
86 |
94 |
95 |
96 |
104 |
105 |
106 |
114 |
115 |
116 |
124 |
125 |
126 |
127 |
128 | >
129 | );
130 | };
131 |
132 | export default EditUser;
133 |
--------------------------------------------------------------------------------
/channel/wechat-test-account.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "message-pusher/common"
9 | "message-pusher/model"
10 | "net/http"
11 | "time"
12 | )
13 |
14 | type wechatTestAccountResponse struct {
15 | ErrorCode int `json:"errcode"`
16 | ErrorMessage string `json:"errmsg"`
17 | AccessToken string `json:"access_token"`
18 | ExpiresIn int `json:"expires_in"`
19 | }
20 |
21 | type WeChatTestAccountTokenStoreItem struct {
22 | AppID string
23 | AppSecret string
24 | AccessToken string
25 | }
26 |
27 | func (i *WeChatTestAccountTokenStoreItem) Key() string {
28 | return i.AppID + i.AppSecret
29 | }
30 |
31 | func (i *WeChatTestAccountTokenStoreItem) IsShared() bool {
32 | var count int64 = 0
33 | model.DB.Model(&model.Channel{}).Where("secret = ? and app_id = ? and type = ?",
34 | i.AppSecret, i.AppID, model.TypeWeChatTestAccount).Count(&count)
35 | return count > 1
36 | }
37 |
38 | func (i *WeChatTestAccountTokenStoreItem) IsFilled() bool {
39 | return i.AppID != "" && i.AppSecret != ""
40 | }
41 |
42 | func (i *WeChatTestAccountTokenStoreItem) Token() string {
43 | return i.AccessToken
44 | }
45 |
46 | func (i *WeChatTestAccountTokenStoreItem) Refresh() {
47 | // https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
48 | client := http.Client{
49 | Timeout: 5 * time.Second,
50 | }
51 | req, err := http.NewRequest("GET", fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
52 | i.AppID, i.AppSecret), nil)
53 | if err != nil {
54 | common.SysError(err.Error())
55 | return
56 | }
57 | responseData, err := client.Do(req)
58 | if err != nil {
59 | common.SysError("failed to refresh access token: " + err.Error())
60 | return
61 | }
62 | defer responseData.Body.Close()
63 | var res wechatTestAccountResponse
64 | err = json.NewDecoder(responseData.Body).Decode(&res)
65 | if err != nil {
66 | common.SysError("failed to decode wechatTestAccountResponse: " + err.Error())
67 | return
68 | }
69 | if res.ErrorCode != 0 {
70 | common.SysError(res.ErrorMessage)
71 | return
72 | }
73 | i.AccessToken = res.AccessToken
74 | common.SysLog("access token refreshed")
75 | }
76 |
77 | type wechatTestAccountRequestValue struct {
78 | Value string `json:"value"`
79 | }
80 |
81 | type wechatTestMessageRequest struct {
82 | ToUser string `json:"touser"`
83 | TemplateId string `json:"template_id"`
84 | URL string `json:"url"`
85 | Data struct {
86 | Text wechatTestAccountRequestValue `json:"text"` // alias for description, for compatibility
87 | Title wechatTestAccountRequestValue `json:"title"`
88 | Description wechatTestAccountRequestValue `json:"description"`
89 | Content wechatTestAccountRequestValue `json:"content"`
90 | } `json:"data"`
91 | }
92 |
93 | type wechatTestMessageResponse struct {
94 | ErrorCode int `json:"errcode"`
95 | ErrorMessage string `json:"errmsg"`
96 | }
97 |
98 | func SendWeChatTestMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
99 | // https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Template_Message_Interface.html
100 | values := wechatTestMessageRequest{
101 | ToUser: channel_.AccountId,
102 | TemplateId: channel_.Other,
103 | URL: "",
104 | }
105 | if message.To != "" {
106 | values.ToUser = message.To
107 | }
108 | values.Data.Text.Value = message.Description
109 | values.Data.Title.Value = message.Title
110 | values.Data.Description.Value = message.Description
111 | values.Data.Content.Value = message.Content
112 | values.URL = message.URL
113 | jsonData, err := json.Marshal(values)
114 | if err != nil {
115 | return err
116 | }
117 | key := fmt.Sprintf("%s%s", channel_.AppId, channel_.Secret)
118 | accessToken := TokenStoreGetToken(key)
119 | resp, err := http.Post(fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=%s", accessToken), "application/json",
120 | bytes.NewBuffer(jsonData))
121 | if err != nil {
122 | return err
123 | }
124 | var res wechatTestMessageResponse
125 | err = json.NewDecoder(resp.Body).Decode(&res)
126 | if err != nil {
127 | return err
128 | }
129 | if res.ErrorCode != 0 {
130 | return errors.New(res.ErrorMessage)
131 | }
132 | return nil
133 | }
134 |
--------------------------------------------------------------------------------
/model/message.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "errors"
5 | "message-pusher/common"
6 | "time"
7 | )
8 |
9 | type Message struct {
10 | Id int `json:"id"`
11 | UserId int `json:"user_id" gorm:"index"`
12 | Title string `json:"title"`
13 | Description string `json:"description"`
14 | Content string `json:"content"`
15 | URL string `json:"url" gorm:"column:url"`
16 | Channel string `json:"channel"`
17 | Token string `json:"token" gorm:"-:all"`
18 | HTMLContent string `json:"html_content" gorm:"-:all"`
19 | Timestamp int64 `json:"timestamp" gorm:"type:bigint"`
20 | Link string `json:"link" gorm:"unique;index"`
21 | To string `json:"to" gorm:"column:to"` // if specified, will send to this user(s)
22 | Status int `json:"status" gorm:"default:0;index"` // pending, sent, failed
23 | OpenId string `json:"openid" gorm:"-:all"` // alias for to
24 | Desp string `json:"desp" gorm:"-:all"` // alias for content
25 | Short string `json:"short" gorm:"-:all"` // alias for description
26 | Async bool `json:"async" gorm:"-"` // if true, will send message asynchronously
27 | RenderMode string `json:"render_mode" gorm:"raw"` // markdown (default), code, raw
28 | }
29 |
30 | func GetMessageByIds(id int, userId int) (*Message, error) {
31 | if id == 0 || userId == 0 {
32 | return nil, errors.New("id 或 userId 为空!")
33 | }
34 | message := Message{Id: id, UserId: userId}
35 | err := DB.Where(message).First(&message).Error
36 | return &message, err
37 | }
38 |
39 | func GetMessageById(id int) (*Message, error) {
40 | if id == 0 {
41 | return nil, errors.New("id 为空!")
42 | }
43 | message := Message{Id: id}
44 | err := DB.Where(message).First(&message).Error
45 | return &message, err
46 | }
47 |
48 | func GetAsyncPendingMessageIds() (ids []int, err error) {
49 | err = DB.Model(&Message{}).Where("status = ?", common.MessageSendStatusAsyncPending).Pluck("id", &ids).Error
50 | return ids, err
51 | }
52 |
53 | func GetMessageByLink(link string) (*Message, error) {
54 | if link == "" {
55 | return nil, errors.New("link 为空!")
56 | }
57 | message := Message{Link: link}
58 | err := DB.Where(message).First(&message).Error
59 | return &message, err
60 | }
61 |
62 | func GetMessageStatusByLink(link string) (int, error) {
63 | if link == "" {
64 | return common.MessageSendStatusUnknown, errors.New("link 为空!")
65 | }
66 | message := Message{}
67 | err := DB.Where("link = ?", link).Select("status").First(&message).Error
68 | return message.Status, err
69 | }
70 |
71 | func GetMessagesByUserId(userId int, startIdx int, num int) (messages []*Message, err error) {
72 | err = DB.Select([]string{"id", "title", "channel", "timestamp", "status"}).
73 | Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&messages).Error
74 | return messages, err
75 | }
76 |
77 | func SearchMessages(keyword string) (messages []*Message, err error) {
78 | err = DB.Select([]string{"id", "title", "channel", "timestamp", "status"}).
79 | Where("id = ? or title LIKE ? or description LIKE ? or content LIKE ?", keyword, keyword+"%", keyword+"%", keyword+"%").
80 | Order("id desc").
81 | Find(&messages).Error
82 | return messages, err
83 | }
84 |
85 | func DeleteMessageById(id int, userId int) (err error) {
86 | // Why we need userId here? In case user want to delete other's message.
87 | if id == 0 || userId == 0 {
88 | return errors.New("id 或 userId 为空!")
89 | }
90 | message := Message{Id: id, UserId: userId}
91 | err = DB.Where(message).First(&message).Error
92 | if err != nil {
93 | return err
94 | }
95 | return message.Delete()
96 | }
97 |
98 | func DeleteAllMessages() error {
99 | return DB.Exec("DELETE FROM messages").Error
100 | }
101 |
102 | func (message *Message) UpdateAndInsert(userId int) error {
103 | message.Timestamp = time.Now().Unix()
104 | message.UserId = userId
105 | message.Status = common.MessageSendStatusPending
106 | var err error
107 | err = DB.Create(message).Error
108 | return err
109 | }
110 |
111 | func (message *Message) UpdateStatus(status int) error {
112 | err := DB.Model(message).Update("status", status).Error
113 | return err
114 | }
115 |
116 | func (message *Message) Delete() error {
117 | err := DB.Delete(message).Error
118 | return err
119 | }
120 |
--------------------------------------------------------------------------------
/web/src/pages/Home/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect } from 'react';
2 | import { Card, Grid, Header, Segment } from 'semantic-ui-react';
3 | import { API, showError, showNotice, timestamp2string } from '../../helpers';
4 | import { StatusContext } from '../../context/Status';
5 |
6 | const Home = () => {
7 | const [statusState, statusDispatch] = useContext(StatusContext);
8 | const homePageLink = localStorage.getItem('home_page_link') || '';
9 |
10 | const displayNotice = async () => {
11 | const res = await API.get('/api/notice');
12 | const { success, message, data } = res.data;
13 | if (success) {
14 | let oldNotice = localStorage.getItem('notice');
15 | if (data !== oldNotice && data !== '') {
16 | showNotice(data);
17 | localStorage.setItem('notice', data);
18 | }
19 | } else {
20 | showError(message);
21 | }
22 | };
23 |
24 | const getStartTimeString = () => {
25 | const timestamp = statusState?.status?.start_time;
26 | return timestamp2string(timestamp);
27 | };
28 |
29 | useEffect(() => {
30 | displayNotice().then();
31 | }, []);
32 | return (
33 | <>
34 | {homePageLink !== '' ? (
35 | <>
36 |
40 | >
41 | ) : (
42 | <>
43 |
44 |
45 |
46 |
47 |
48 |
49 | 系统信息
50 | 系统信息总览
51 |
52 | 名称:{statusState?.status?.system_name}
53 | 版本:{statusState?.status?.version}
54 |
55 | 源码:
56 |
60 | https://github.com/songquanpeng/message-pusher
61 |
62 |
63 | 启动时间:{getStartTimeString()}
64 | 自从上次启动已发送消息数目:{statusState?.status?.message_count}
65 | 自从上次启动新注册用户数目:{statusState?.status?.user_count}
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | 系统配置
74 | 系统配置总览
75 |
76 |
77 | 邮箱验证:
78 | {statusState?.status?.email_verification === true
79 | ? '已启用'
80 | : '未启用'}
81 |
82 |
83 | GitHub 身份验证:
84 | {statusState?.status?.github_oauth === true
85 | ? '已启用'
86 | : '未启用'}
87 |
88 |
89 | 微信身份验证:
90 | {statusState?.status?.wechat_login === true
91 | ? '已启用'
92 | : '未启用'}
93 |
94 |
95 | Turnstile 用户校验:
96 | {statusState?.status?.turnstile_check === true
97 | ? '已启用'
98 | : '未启用'}
99 |
100 |
101 | 全局消息持久化:
102 | {statusState?.status?.message_persistence === true
103 | ? '已启用'
104 | : '未启用'}
105 |
106 |
107 | 全局消息渲染:
108 | {statusState?.status?.message_render === true
109 | ? '已启用'
110 | : '未启用'}
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | >
119 | )}
120 | >
121 | );
122 | };
123 |
124 | export default Home;
125 |
--------------------------------------------------------------------------------
/router/api-router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "message-pusher/controller"
6 | "message-pusher/middleware"
7 | )
8 |
9 | func SetApiRouter(router *gin.Engine) {
10 | apiRouter := router.Group("/api")
11 | apiRouter.Use(middleware.GlobalAPIRateLimit())
12 | {
13 | apiRouter.GET("/status", controller.GetStatus)
14 | apiRouter.GET("/notice", controller.GetNotice)
15 | apiRouter.GET("/about", controller.GetAbout)
16 | apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
17 | apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
18 | apiRouter.GET("/register_client/:username", middleware.CriticalRateLimit(), controller.RegisterClient)
19 | apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
20 | apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
21 | apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
22 | apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
23 | apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
24 |
25 | userRoute := apiRouter.Group("/user")
26 | {
27 | userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
28 | userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login)
29 | userRoute.GET("/logout", controller.Logout)
30 |
31 | selfRoute := userRoute.Group("/")
32 | selfRoute.Use(middleware.UserAuth())
33 | {
34 | selfRoute.GET("/self", controller.GetSelf)
35 | selfRoute.PUT("/self", controller.UpdateSelf)
36 | selfRoute.DELETE("/self", controller.DeleteSelf)
37 | selfRoute.GET("/token", controller.GenerateToken)
38 | }
39 |
40 | adminRoute := userRoute.Group("/")
41 | adminRoute.Use(middleware.AdminAuth())
42 | {
43 | adminRoute.GET("/", controller.GetAllUsers)
44 | adminRoute.GET("/search", controller.SearchUsers)
45 | adminRoute.GET("/:id", controller.GetUser)
46 | adminRoute.POST("/", controller.CreateUser)
47 | adminRoute.POST("/manage", controller.ManageUser)
48 | adminRoute.PUT("/", controller.UpdateUser)
49 | adminRoute.DELETE("/:id", controller.DeleteUser)
50 | }
51 | }
52 | optionRoute := apiRouter.Group("/option")
53 | optionRoute.Use(middleware.RootAuth())
54 | {
55 | optionRoute.GET("/", controller.GetOptions)
56 | optionRoute.PUT("/", controller.UpdateOption)
57 | }
58 | messageRoute := apiRouter.Group("/message")
59 | {
60 | messageRoute.GET("/", middleware.UserAuth(), controller.GetUserMessages)
61 | messageRoute.GET("/stream", middleware.UserAuth(), middleware.SetSSEHeaders(), controller.GetNewMessages)
62 | messageRoute.GET("/search", middleware.UserAuth(), controller.SearchMessages)
63 | messageRoute.GET("/status/:link", controller.GetMessageStatus)
64 | messageRoute.POST("/resend/:id", middleware.UserAuth(), controller.ResendMessage)
65 | messageRoute.GET("/:id", middleware.UserAuth(), controller.GetMessage)
66 | messageRoute.DELETE("/", middleware.RootAuth(), controller.DeleteAllMessages)
67 | messageRoute.DELETE("/:id", middleware.UserAuth(), controller.DeleteMessage)
68 | }
69 | channelRoute := apiRouter.Group("/channel")
70 | channelRoute.Use(middleware.UserAuth())
71 | {
72 | channelRoute.GET("/", controller.GetAllChannels)
73 | channelRoute.GET("/search", controller.SearchChannels)
74 | channelRoute.GET("/:id", controller.GetChannel)
75 | channelRoute.POST("/", controller.AddChannel)
76 | channelRoute.PUT("/", controller.UpdateChannel)
77 | channelRoute.DELETE("/:id", controller.DeleteChannel)
78 | }
79 | webhookRoute := apiRouter.Group("/webhook")
80 | webhookRoute.Use(middleware.UserAuth())
81 | {
82 | webhookRoute.GET("/", controller.GetAllWebhooks)
83 | webhookRoute.GET("/search", controller.SearchWebhooks)
84 | webhookRoute.GET("/:id", controller.GetWebhook)
85 | webhookRoute.POST("/", controller.AddWebhook)
86 | webhookRoute.PUT("/", controller.UpdateWebhook)
87 | webhookRoute.DELETE("/:id", controller.DeleteWebhook)
88 | }
89 | }
90 | pushRouter := router.Group("/push")
91 | pushRouter.Use(middleware.GlobalAPIRateLimit())
92 | {
93 | pushRouter.GET("/:username", controller.GetPushMessage)
94 | pushRouter.POST("/:username", controller.PostPushMessage)
95 | }
96 | webhookRouter := router.Group("/webhook")
97 | webhookRouter.Use(middleware.GlobalAPIRateLimit())
98 | {
99 | webhookRouter.POST("/:link", controller.TriggerWebhook)
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/model/channel.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "errors"
5 | "message-pusher/common"
6 | )
7 |
8 | const (
9 | TypeEmail = "email"
10 | TypeWeChatTestAccount = "test"
11 | TypeWeChatCorpAccount = "corp_app"
12 | TypeCorp = "corp"
13 | TypeLark = "lark"
14 | TypeDing = "ding"
15 | TypeTelegram = "telegram"
16 | TypeDiscord = "discord"
17 | TypeBark = "bark"
18 | TypeClient = "client"
19 | TypeNone = "none"
20 | TypeOneBot = "one_bot"
21 | TypeGroup = "group"
22 | TypeLarkApp = "lark_app"
23 | TypeCustom = "custom"
24 | TypeTencentAlarm = "tencent_alarm"
25 | )
26 |
27 | type Channel struct {
28 | Id int `json:"id"`
29 | Type string `json:"type" gorm:"type:varchar(32)"`
30 | UserId int `json:"user_id" gorm:"uniqueIndex:name_user_id;index"`
31 | Name string `json:"name" gorm:"type:varchar(32);uniqueIndex:name_user_id"`
32 | Description string `json:"description"`
33 | Status int `json:"status" gorm:"default:1"` // enabled, disabled
34 | Secret string `json:"secret" gorm:"index"`
35 | AppId string `json:"app_id"`
36 | AccountId string `json:"account_id"`
37 | URL string `json:"url" gorm:"column:url"`
38 | Other string `json:"other"`
39 | CreatedTime int64 `json:"created_time" gorm:"bigint"`
40 | Token *string `json:"token" gorm:"token"`
41 | }
42 |
43 | type BriefChannel struct {
44 | Id int `json:"id"`
45 | Name string `json:"name"`
46 | Description string `json:"description"`
47 | }
48 |
49 | func GetChannelById(id int, userId int, selectAll bool) (*Channel, error) {
50 | if id == 0 || userId == 0 {
51 | return nil, errors.New("id 或 userId 为空!")
52 | }
53 | c := Channel{Id: id, UserId: userId}
54 | var err error
55 | if selectAll {
56 | err = DB.Where(c).First(&c).Error
57 | } else {
58 | err = DB.Omit("secret").Where(c).First(&c).Error
59 | }
60 | return &c, err
61 | }
62 |
63 | func GetChannelByName(name string, userId int) (*Channel, error) {
64 | if name == "" || userId == 0 {
65 | return nil, errors.New("name 或 userId 为空!")
66 | }
67 | c := Channel{Name: name, UserId: userId}
68 | err := DB.Where(c).First(&c).Error
69 | return &c, err
70 | }
71 |
72 | func GetTokenStoreChannels() (channels []*Channel, err error) {
73 | err = DB.Where("type in ?", []string{TypeWeChatCorpAccount, TypeWeChatTestAccount, TypeLarkApp}).Find(&channels).Error
74 | return channels, err
75 | }
76 |
77 | func GetTokenStoreChannelsByUserId(userId int) (channels []*Channel, err error) {
78 | err = DB.Where("user_id = ?", userId).Where("type = ? or type = ?", TypeWeChatCorpAccount, TypeWeChatTestAccount).Find(&channels).Error
79 | return channels, err
80 | }
81 |
82 | func GetChannelsByUserId(userId int, startIdx int, num int) (channels []*Channel, err error) {
83 | err = DB.Omit("secret").Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&channels).Error
84 | return channels, err
85 | }
86 |
87 | func GetBriefChannelsByUserId(userId int) (channels []*BriefChannel, err error) {
88 | err = DB.Model(&Channel{}).Select("id", "name", "description").Where("user_id = ? and status = ?", userId, common.ChannelStatusEnabled).Find(&channels).Error
89 | return channels, err
90 | }
91 |
92 | func SearchChannels(userId int, keyword string) (channels []*Channel, err error) {
93 | err = DB.Omit("secret").Where("user_id = ?", userId).Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&channels).Error
94 | return channels, err
95 | }
96 |
97 | func DeleteChannelById(id int, userId int) (c *Channel, err error) {
98 | // Why we need userId here? In case user want to delete other's c.
99 | if id == 0 || userId == 0 {
100 | return nil, errors.New("id 或 userId 为空!")
101 | }
102 | c = &Channel{Id: id, UserId: userId}
103 | err = DB.Where(c).First(&c).Error
104 | if err != nil {
105 | return nil, err
106 | }
107 | return c, c.Delete()
108 | }
109 |
110 | func (channel *Channel) Insert() error {
111 | var err error
112 | err = DB.Create(channel).Error
113 | return err
114 | }
115 |
116 | func (channel *Channel) UpdateStatus(status int) error {
117 | err := DB.Model(channel).Update("status", status).Error
118 | return err
119 | }
120 |
121 | // Update Make sure your token's fields is completed, because this will update non-zero values
122 | func (channel *Channel) Update() error {
123 | var err error
124 | err = DB.Model(channel).Select("type", "name", "description", "secret", "app_id", "account_id", "url", "other", "status", "token").Updates(channel).Error
125 | return err
126 | }
127 |
128 | func (channel *Channel) Delete() error {
129 | err := DB.Delete(channel).Error
130 | return err
131 | }
132 |
--------------------------------------------------------------------------------
/channel/client.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "github.com/gorilla/websocket"
7 | "message-pusher/common"
8 | "message-pusher/model"
9 | "sync"
10 | "time"
11 | )
12 |
13 | const (
14 | writeWait = 10 * time.Second
15 | pongWait = 60 * time.Second
16 | pingPeriod = (pongWait * 9) / 10
17 | maxMessageSize = 512
18 | )
19 |
20 | type webSocketClient struct {
21 | key string
22 | conn *websocket.Conn
23 | message chan *model.Message
24 | pong chan bool
25 | stop chan bool
26 | timestamp int64
27 | }
28 |
29 | func (c *webSocketClient) handleDataReading() {
30 | c.conn.SetReadLimit(maxMessageSize)
31 | _ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
32 | c.conn.SetPongHandler(func(string) error {
33 | return c.conn.SetReadDeadline(time.Now().Add(pongWait))
34 | })
35 | for {
36 | messageType, _, err := c.conn.ReadMessage()
37 | if err != nil {
38 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) {
39 | common.SysError("error read WebSocket client: " + err.Error())
40 | }
41 | c.close()
42 | break
43 | }
44 | switch messageType {
45 | case websocket.PingMessage:
46 | c.pong <- true
47 | case websocket.CloseMessage:
48 | c.close()
49 | break
50 | }
51 | }
52 | }
53 |
54 | func (c *webSocketClient) handleDataWriting() {
55 | pingTicker := time.NewTicker(pingPeriod)
56 | defer func() {
57 | pingTicker.Stop()
58 | clientConnMapMutex.Lock()
59 | client, ok := clientMap[c.key]
60 | // otherwise we may delete the new added client!
61 | if ok && client.timestamp == c.timestamp {
62 | delete(clientMap, c.key)
63 | }
64 | clientConnMapMutex.Unlock()
65 | err := c.conn.Close()
66 | if err != nil {
67 | common.SysError("error close WebSocket client: " + err.Error())
68 | }
69 | }()
70 | for {
71 | select {
72 | case message := <-c.message:
73 | _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
74 | err := c.conn.WriteJSON(message)
75 | if err != nil {
76 | common.SysError("error write data to WebSocket client: " + err.Error())
77 | return
78 | }
79 | case <-c.pong:
80 | err := c.conn.WriteMessage(websocket.PongMessage, nil)
81 | if err != nil {
82 | common.SysError("error send pong to WebSocket client: " + err.Error())
83 | return
84 | }
85 | case <-pingTicker.C:
86 | _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
87 | err := c.conn.WriteMessage(websocket.PingMessage, nil)
88 | if err != nil {
89 | common.SysError("error write data to WebSocket client: " + err.Error())
90 | return
91 | }
92 | case <-c.stop:
93 | err := c.conn.WriteMessage(websocket.CloseMessage, nil)
94 | if err != nil {
95 | common.SysError("error write data to WebSocket client: " + err.Error())
96 | }
97 | return
98 | }
99 | }
100 | }
101 |
102 | func (c *webSocketClient) sendMessage(message *model.Message) {
103 | c.message <- message
104 | }
105 |
106 | func (c *webSocketClient) close() {
107 | // should only be called once
108 | c.stop <- true
109 | // the defer function in handleDataWriting will do the cleanup
110 | }
111 |
112 | var clientMap map[string]*webSocketClient
113 | var clientConnMapMutex sync.Mutex
114 |
115 | func init() {
116 | clientConnMapMutex.Lock()
117 | clientMap = make(map[string]*webSocketClient)
118 | clientConnMapMutex.Unlock()
119 | }
120 |
121 | func RegisterClient(channelName string, userId int, conn *websocket.Conn) {
122 | key := fmt.Sprintf("%s:%d", channelName, userId)
123 | clientConnMapMutex.Lock()
124 | oldClient, existed := clientMap[key]
125 | clientConnMapMutex.Unlock()
126 | if existed {
127 | byeMessage := &model.Message{
128 | Title: common.SystemName,
129 | Description: "其他客户端已连接服务器,本客户端已被挤下线!",
130 | }
131 | oldClient.sendMessage(byeMessage)
132 | oldClient.close()
133 | }
134 | helloMessage := &model.Message{
135 | Title: common.SystemName,
136 | Description: "客户端连接成功!",
137 | }
138 | newClient := &webSocketClient{
139 | key: key,
140 | conn: conn,
141 | message: make(chan *model.Message),
142 | pong: make(chan bool),
143 | stop: make(chan bool),
144 | timestamp: time.Now().UnixMilli(),
145 | }
146 | go newClient.handleDataWriting()
147 | go newClient.handleDataReading()
148 | defer newClient.sendMessage(helloMessage)
149 | clientConnMapMutex.Lock()
150 | clientMap[key] = newClient
151 | clientConnMapMutex.Unlock()
152 | }
153 |
154 | func SendClientMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
155 | key := fmt.Sprintf("%s:%d", channel_.Name, user.Id)
156 | clientConnMapMutex.Lock()
157 | client, existed := clientMap[key]
158 | clientConnMapMutex.Unlock()
159 | if !existed {
160 | return errors.New("客户端未连接")
161 | }
162 | client.sendMessage(message)
163 | return nil
164 | }
165 |
--------------------------------------------------------------------------------
/bin/migrate_v3_to_v4.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import argparse
4 | import sqlite3
5 | import time
6 |
7 |
8 | def get_timestamp():
9 | return int(time.time())
10 |
11 |
12 | def main(args):
13 | conn = sqlite3.connect(args.db_path)
14 | cursor = conn.cursor()
15 | cursor.execute("DELETE FROM channels")
16 | res = cursor.execute("SELECT * FROM users")
17 | users = res.fetchall()
18 | for id, username, password, display_name, role, status, token, email, github_id, wechat_id, channel, wechat_test_account_id, wechat_test_account_secret, wechat_test_account_template_id, wechat_test_account_open_id, wechat_test_account_verification_token, wechat_corp_account_id, wechat_corp_account_agent_secret, wechat_corp_account_agent_id, wechat_corp_account_user_id, wechat_corp_account_client_type, corp_webhook_url, lark_webhook_url, lark_webhook_secret, ding_webhook_url, ding_webhook_secret, bark_server, bark_secret, client_secret, telegram_bot_token, telegram_chat_id, discord_webhook_url, send_email_to_others, save_message_to_database in users:
19 | if email:
20 | cursor.execute(
21 | "INSERT INTO channels "
22 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
23 | ('email', id, 'email', '', 1, '', '', '', '', '', get_timestamp()))
24 | if wechat_test_account_id:
25 | cursor.execute(
26 | "INSERT INTO channels "
27 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
28 | ('test', id, 'test', '', 1, wechat_test_account_secret, wechat_test_account_id,
29 | wechat_test_account_open_id, '', wechat_test_account_template_id, get_timestamp()))
30 | if wechat_corp_account_id:
31 | cursor.execute(
32 | "INSERT INTO channels "
33 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
34 | ('corp_app', id, 'corp_app', '', 1, wechat_corp_account_agent_secret,
35 | f"{wechat_corp_account_id}|{wechat_corp_account_agent_id}",
36 | wechat_corp_account_user_id, '', wechat_corp_account_client_type, get_timestamp()))
37 | if lark_webhook_url:
38 | cursor.execute(
39 | "INSERT INTO channels "
40 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
41 | ('lark', id, 'lark', '', 1, lark_webhook_secret, '', '', lark_webhook_url, '', get_timestamp()))
42 | if ding_webhook_url:
43 | cursor.execute(
44 | "INSERT INTO channels "
45 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
46 | ('ding', id, 'ding', '', 1, ding_webhook_secret, '', '', ding_webhook_url, '', get_timestamp()))
47 | if corp_webhook_url:
48 | cursor.execute(
49 | "INSERT INTO channels "
50 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
51 | ('corp', id, 'corp', '', 1, '', '', '', corp_webhook_url, '', get_timestamp()))
52 | if bark_server:
53 | cursor.execute(
54 | "INSERT INTO channels "
55 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
56 | ('bark', id, 'bark', '', 1, bark_secret, '', '', bark_server, '', get_timestamp()))
57 | if telegram_bot_token:
58 | cursor.execute(
59 | "INSERT INTO channels "
60 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
61 | ('telegram', id, 'telegram', '', 1, telegram_bot_token, '', telegram_chat_id, '', '', get_timestamp()))
62 | if discord_webhook_url:
63 | cursor.execute(
64 | "INSERT INTO channels "
65 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
66 | ('discord', id, 'discord', '', 1, '', '', '', discord_webhook_url, '', get_timestamp()))
67 | if client_secret:
68 | cursor.execute(
69 | "INSERT INTO channels "
70 | "(type,user_id,name,description,status,secret,app_id,account_id,url,other,created_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
71 | ('client', id, 'client', '', 1, client_secret, '', '', '', '', get_timestamp()))
72 | conn.commit()
73 |
74 |
75 | if __name__ == '__main__':
76 | parser = argparse.ArgumentParser()
77 | parser.add_argument('--db_path', type=str, default='./message-pusher.db')
78 | main(parser.parse_args())
79 |
--------------------------------------------------------------------------------
/channel/lark-app.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "message-pusher/common"
9 | "message-pusher/model"
10 | "net/http"
11 | "strings"
12 | )
13 |
14 | type larkAppTokenRequest struct {
15 | AppID string `json:"app_id"`
16 | AppSecret string `json:"app_secret"`
17 | }
18 |
19 | type larkAppTokenResponse struct {
20 | Code int `json:"code"`
21 | Msg string `json:"msg"`
22 | TenantAccessToken string `json:"tenant_access_token"`
23 | Expire int `json:"expire"`
24 | }
25 |
26 | type LarkAppTokenStoreItem struct {
27 | AppID string
28 | AppSecret string
29 | AccessToken string
30 | }
31 |
32 | func (i *LarkAppTokenStoreItem) Key() string {
33 | return i.AppID + i.AppSecret
34 | }
35 |
36 | func (i *LarkAppTokenStoreItem) IsShared() bool {
37 | var count int64 = 0
38 | model.DB.Model(&model.Channel{}).Where("secret = ? and app_id = ? and type = ?",
39 | i.AppSecret, i.AppID, model.TypeLarkApp).Count(&count)
40 | return count > 1
41 | }
42 |
43 | func (i *LarkAppTokenStoreItem) IsFilled() bool {
44 | return i.AppID != "" && i.AppSecret != ""
45 | }
46 |
47 | func (i *LarkAppTokenStoreItem) Token() string {
48 | return i.AccessToken
49 | }
50 |
51 | func (i *LarkAppTokenStoreItem) Refresh() {
52 | // https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal
53 | tokenRequest := larkAppTokenRequest{
54 | AppID: i.AppID,
55 | AppSecret: i.AppSecret,
56 | }
57 | tokenRequestData, err := json.Marshal(tokenRequest)
58 | responseData, err := http.Post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
59 | "application/json; charset=utf-8", bytes.NewBuffer(tokenRequestData))
60 | if err != nil {
61 | common.SysError("failed to refresh access token: " + err.Error())
62 | return
63 | }
64 | defer responseData.Body.Close()
65 | var res larkAppTokenResponse
66 | err = json.NewDecoder(responseData.Body).Decode(&res)
67 | if err != nil {
68 | common.SysError("failed to decode larkAppTokenResponse: " + err.Error())
69 | return
70 | }
71 | if res.Code != 0 {
72 | common.SysError(res.Msg)
73 | return
74 | }
75 | i.AccessToken = res.TenantAccessToken
76 | common.SysLog("access token refreshed")
77 | }
78 |
79 | type larkAppMessageRequest struct {
80 | ReceiveId string `json:"receive_id"`
81 | MsgType string `json:"msg_type"`
82 | Content string `json:"content"`
83 | }
84 |
85 | type larkAppMessageResponse struct {
86 | Code int `json:"code"`
87 | Msg string `json:"msg"`
88 | }
89 |
90 | func parseLarkAppTarget(target string) (string, string, error) {
91 | parts := strings.Split(target, ":")
92 | if len(parts) != 2 {
93 | return "", "", errors.New("无效的飞书应用号消息接收者参数")
94 | }
95 | return parts[0], parts[1], nil
96 | }
97 |
98 | func SendLarkAppMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
99 | // https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create
100 | rawTarget := message.To
101 | if rawTarget == "" {
102 | rawTarget = channel_.AccountId
103 | }
104 | targetType, target, err := parseLarkAppTarget(rawTarget)
105 | if err != nil {
106 | return err
107 | }
108 | request := larkAppMessageRequest{
109 | ReceiveId: target,
110 | }
111 | atPrefix := getLarkAtPrefix(message)
112 | if message.Description != "" {
113 | request.MsgType = "text"
114 | content := larkTextContent{Text: atPrefix + message.Description}
115 | contentData, err := json.Marshal(content)
116 | if err != nil {
117 | return err
118 | }
119 | request.Content = string(contentData)
120 | } else {
121 | request.MsgType = "interactive"
122 | content := larkCardContent{}
123 | content.Config.WideScreenMode = true
124 | content.Config.EnableForward = true
125 | content.Elements = append(content.Elements, larkMessageRequestCardElement{
126 | Tag: "div",
127 | Text: larkMessageRequestCardElementText{
128 | Content: atPrefix + message.Content,
129 | Tag: "lark_md",
130 | },
131 | })
132 | contentData, err := json.Marshal(content)
133 | if err != nil {
134 | return err
135 | }
136 | request.Content = string(contentData)
137 | }
138 | requestData, err := json.Marshal(request)
139 | if err != nil {
140 | return err
141 | }
142 | key := fmt.Sprintf("%s%s", channel_.AppId, channel_.Secret)
143 | accessToken := TokenStoreGetToken(key)
144 | url := fmt.Sprintf("https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=%s", targetType)
145 | req, _ := http.NewRequest("POST", url, bytes.NewReader(requestData))
146 | req.Header.Set("Authorization", "Bearer "+accessToken)
147 | req.Header.Set("Content-Type", "application/json; charset=utf-8")
148 | resp, err := http.DefaultClient.Do(req)
149 | if err != nil {
150 | return err
151 | }
152 | var res larkAppMessageResponse
153 | err = json.NewDecoder(resp.Body).Decode(&res)
154 | if err != nil {
155 | return err
156 | }
157 | if res.Code != 0 {
158 | return errors.New(res.Msg)
159 | }
160 | return nil
161 | }
162 |
--------------------------------------------------------------------------------
/web/src/pages/Message/EditMessage.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Header, Segment } from 'semantic-ui-react';
3 | import { useParams } from 'react-router-dom';
4 | import { API, showError, showSuccess } from '../../helpers';
5 | import { loadUser, loadUserChannels } from '../../helpers/loader';
6 |
7 | const EditMessage = () => {
8 | const params = useParams();
9 | const messageId = params.id;
10 | const isEditing = messageId !== undefined;
11 | let [user, setUser] = useState({
12 | id: '',
13 | username: '',
14 | channel: '',
15 | token: '',
16 | });
17 | let [channels, setChannels] = useState([]);
18 | const [loading, setLoading] = useState(isEditing);
19 | const originInputs = {
20 | title: '',
21 | description: '',
22 | content: '',
23 | url: '',
24 | channel: localStorage.getItem('editor_channel') || '',
25 | to: '',
26 | async: false,
27 | };
28 |
29 | const [inputs, setInputs] = useState(originInputs);
30 | const { title, description, content, url, channel, to, async } = inputs;
31 |
32 | const handleInputChange = (e, { name, value }) => {
33 | setInputs((inputs) => ({ ...inputs, [name]: value }));
34 | if (name === "channel") {
35 | localStorage.setItem('editor_channel', value);
36 | }
37 | };
38 |
39 | const loadMessage = async () => {
40 | let res = await API.get(`/api/message/${messageId}`);
41 | const { success, message, data } = res.data;
42 | if (success) {
43 | data.id = 0;
44 | setInputs(data);
45 | } else {
46 | showError(message);
47 | }
48 | setLoading(false);
49 | };
50 |
51 | useEffect(() => {
52 | if (isEditing) {
53 | loadMessage().then();
54 | }
55 | const loader = async () => {
56 | let user = await loadUser();
57 | if (user) {
58 | setUser(user);
59 | }
60 | let channels = await loadUserChannels();
61 | if (channels) {
62 | setChannels(channels);
63 | }
64 | };
65 | loader().then();
66 | }, []);
67 |
68 | const send = async () => {
69 | if (!description && !content) return;
70 | let res = await API.post(`/push/${user.username}/`, {
71 | ...inputs,
72 | token: user.token,
73 | });
74 | const { success, message } = res.data;
75 | if (success) {
76 | if (isEditing) {
77 | showSuccess('消息重发成功!');
78 | } else {
79 | showSuccess('消息发送成功!');
80 | setInputs(originInputs);
81 | }
82 | } else {
83 | showError(message);
84 | }
85 | };
86 |
87 | return (
88 | <>
89 |
90 |
91 |
93 |
100 |
107 |
115 |
116 |
117 |
124 |
125 |
126 |
134 |
135 |
136 |
144 |
145 |
148 |
156 |
157 |
158 | >
159 | );
160 | };
161 |
162 | export default EditMessage;
163 |
--------------------------------------------------------------------------------
/channel/wechat-corp-account.go:
--------------------------------------------------------------------------------
1 | package channel
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "message-pusher/common"
9 | "message-pusher/model"
10 | "net/http"
11 | "strings"
12 | "time"
13 | )
14 |
15 | type wechatCorpAccountResponse struct {
16 | ErrorCode int `json:"errcode"`
17 | ErrorMessage string `json:"errmsg"`
18 | AccessToken string `json:"access_token"`
19 | ExpiresIn int `json:"expires_in"`
20 | }
21 |
22 | type WeChatCorpAccountTokenStoreItem struct {
23 | CorpId string
24 | AgentSecret string
25 | AgentId string
26 | AccessToken string
27 | }
28 |
29 | func (i *WeChatCorpAccountTokenStoreItem) Key() string {
30 | return i.CorpId + i.AgentId + i.AgentSecret
31 | }
32 |
33 | func (i *WeChatCorpAccountTokenStoreItem) IsShared() bool {
34 | appId := fmt.Sprintf("%s|%s", i.CorpId, i.AgentId)
35 | var count int64 = 0
36 | model.DB.Model(&model.Channel{}).Where("secret = ? and app_id = ? and type = ?",
37 | i.AgentSecret, appId, model.TypeWeChatCorpAccount).Count(&count)
38 | return count > 1
39 | }
40 |
41 | func (i *WeChatCorpAccountTokenStoreItem) IsFilled() bool {
42 | return i.CorpId != "" && i.AgentSecret != "" && i.AgentId != ""
43 | }
44 |
45 | func (i *WeChatCorpAccountTokenStoreItem) Token() string {
46 | return i.AccessToken
47 | }
48 |
49 | func (i *WeChatCorpAccountTokenStoreItem) Refresh() {
50 | // https://work.weixin.qq.com/api/doc/90000/90135/91039
51 | client := http.Client{
52 | Timeout: 5 * time.Second,
53 | }
54 | req, err := http.NewRequest("GET", fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
55 | i.CorpId, i.AgentSecret), nil)
56 | if err != nil {
57 | common.SysError(err.Error())
58 | return
59 | }
60 | responseData, err := client.Do(req)
61 | if err != nil {
62 | common.SysError("failed to refresh access token: " + err.Error())
63 | return
64 | }
65 | defer responseData.Body.Close()
66 | var res wechatCorpAccountResponse
67 | err = json.NewDecoder(responseData.Body).Decode(&res)
68 | if err != nil {
69 | common.SysError("failed to decode wechatCorpAccountResponse: " + err.Error())
70 | return
71 | }
72 | if res.ErrorCode != 0 {
73 | common.SysError(res.ErrorMessage)
74 | return
75 | }
76 | i.AccessToken = res.AccessToken
77 | common.SysLog("access token refreshed")
78 | }
79 |
80 | type wechatCorpMessageRequest struct {
81 | MessageType string `json:"msgtype"`
82 | ToUser string `json:"touser"`
83 | AgentId string `json:"agentid"`
84 | TextCard struct {
85 | Title string `json:"title"`
86 | Description string `json:"description"`
87 | URL string `json:"url"`
88 | } `json:"textcard"`
89 | Text struct {
90 | Content string `json:"content"`
91 | } `json:"text"`
92 | Markdown struct {
93 | Content string `json:"content"`
94 | } `json:"markdown"`
95 | }
96 |
97 | type wechatCorpMessageResponse struct {
98 | ErrorCode int `json:"errcode"`
99 | ErrorMessage string `json:"errmsg"`
100 | }
101 |
102 | func parseWechatCorpAccountAppId(appId string) (string, string, error) {
103 | parts := strings.Split(appId, "|")
104 | if len(parts) != 2 {
105 | return "", "", errors.New("无效的微信企业号配置")
106 | }
107 | return parts[0], parts[1], nil
108 | }
109 |
110 | func SendWeChatCorpMessage(message *model.Message, user *model.User, channel_ *model.Channel) error {
111 | // https://developer.work.weixin.qq.com/document/path/90236
112 | corpId, agentId, err := parseWechatCorpAccountAppId(channel_.AppId)
113 | if err != nil {
114 | return err
115 | }
116 | userId := channel_.AccountId
117 | clientType := channel_.Other
118 | agentSecret := channel_.Secret
119 | messageRequest := wechatCorpMessageRequest{
120 | ToUser: userId,
121 | AgentId: agentId,
122 | }
123 | if message.To != "" {
124 | messageRequest.ToUser = message.To
125 | }
126 | if message.Content == "" {
127 | if message.Title == "" {
128 | messageRequest.MessageType = "text"
129 | messageRequest.Text.Content = message.Description
130 | } else {
131 | messageRequest.MessageType = "textcard"
132 | messageRequest.TextCard.Title = message.Title
133 | messageRequest.TextCard.Description = message.Description
134 | messageRequest.TextCard.URL = common.ServerAddress
135 | }
136 | } else {
137 | if clientType == "plugin" {
138 | messageRequest.MessageType = "textcard"
139 | messageRequest.TextCard.Title = message.Title
140 | messageRequest.TextCard.Description = message.Description
141 | messageRequest.TextCard.URL = message.URL
142 | } else {
143 | messageRequest.MessageType = "markdown"
144 | messageRequest.Markdown.Content = message.Content
145 | }
146 | }
147 | jsonData, err := json.Marshal(messageRequest)
148 | if err != nil {
149 | return err
150 | }
151 | key := fmt.Sprintf("%s%s%s", corpId, agentId, agentSecret)
152 | accessToken := TokenStoreGetToken(key)
153 | resp, err := http.Post(fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=%s", accessToken), "application/json",
154 | bytes.NewBuffer(jsonData))
155 | if err != nil {
156 | return err
157 | }
158 | var res wechatCorpMessageResponse
159 | err = json.NewDecoder(resp.Body).Decode(&res)
160 | if err != nil {
161 | return err
162 | }
163 | if res.ErrorCode != 0 {
164 | return errors.New(res.ErrorMessage)
165 | }
166 | return nil
167 | }
168 |
--------------------------------------------------------------------------------
/controller/misc.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "github.com/gin-gonic/gin"
7 | "message-pusher/common"
8 | "message-pusher/model"
9 | "net/http"
10 | )
11 |
12 | func GetStatus(c *gin.Context) {
13 | c.JSON(http.StatusOK, gin.H{
14 | "success": true,
15 | "message": "",
16 | "data": gin.H{
17 | "version": common.Version,
18 | "start_time": common.StartTime,
19 | "email_verification": common.EmailVerificationEnabled,
20 | "github_oauth": common.GitHubOAuthEnabled,
21 | "github_client_id": common.GitHubClientId,
22 | "system_name": common.SystemName,
23 | "home_page_link": common.HomePageLink,
24 | "footer_html": common.Footer,
25 | "wechat_qrcode": common.WeChatAccountQRCodeImageURL,
26 | "wechat_login": common.WeChatAuthEnabled,
27 | "server_address": common.ServerAddress,
28 | "turnstile_check": common.TurnstileCheckEnabled,
29 | "turnstile_site_key": common.TurnstileSiteKey,
30 | "message_persistence": common.MessagePersistenceEnabled,
31 | "message_render": common.MessageRenderEnabled,
32 | "message_count": common.MessageCount,
33 | "user_count": common.UserCount,
34 | },
35 | })
36 | return
37 | }
38 |
39 | func GetNotice(c *gin.Context) {
40 | common.OptionMapRWMutex.RLock()
41 | defer common.OptionMapRWMutex.RUnlock()
42 | c.JSON(http.StatusOK, gin.H{
43 | "success": true,
44 | "message": "",
45 | "data": common.OptionMap["Notice"],
46 | })
47 | return
48 | }
49 |
50 | func GetAbout(c *gin.Context) {
51 | common.OptionMapRWMutex.RLock()
52 | defer common.OptionMapRWMutex.RUnlock()
53 | c.JSON(http.StatusOK, gin.H{
54 | "success": true,
55 | "message": "",
56 | "data": common.OptionMap["About"],
57 | })
58 | return
59 | }
60 |
61 | func SendEmailVerification(c *gin.Context) {
62 | email := c.Query("email")
63 | if err := common.Validate.Var(email, "required,email"); err != nil {
64 | c.JSON(http.StatusOK, gin.H{
65 | "success": false,
66 | "message": "无效的参数",
67 | })
68 | return
69 | }
70 | if model.IsEmailAlreadyTaken(email) {
71 | c.JSON(http.StatusOK, gin.H{
72 | "success": false,
73 | "message": "邮箱地址已被占用",
74 | })
75 | return
76 | }
77 | code := common.GenerateVerificationCode(6)
78 | common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose)
79 | subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
80 | content := fmt.Sprintf("您好,你正在进行%s邮箱验证。
"+
81 | "您的验证码为: %s
"+
82 | "验证码 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, code, common.VerificationValidMinutes)
83 | err := common.SendEmail(subject, email, content)
84 | if err != nil {
85 | c.JSON(http.StatusOK, gin.H{
86 | "success": false,
87 | "message": err.Error(),
88 | })
89 | return
90 | }
91 | c.JSON(http.StatusOK, gin.H{
92 | "success": true,
93 | "message": "",
94 | })
95 | return
96 | }
97 |
98 | func SendPasswordResetEmail(c *gin.Context) {
99 | email := c.Query("email")
100 | if err := common.Validate.Var(email, "required,email"); err != nil {
101 | c.JSON(http.StatusOK, gin.H{
102 | "success": false,
103 | "message": "无效的参数",
104 | })
105 | return
106 | }
107 | if !model.IsEmailAlreadyTaken(email) {
108 | c.JSON(http.StatusOK, gin.H{
109 | "success": false,
110 | "message": "该邮箱地址未注册",
111 | })
112 | return
113 | }
114 | code := common.GenerateVerificationCode(0)
115 | common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
116 | link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code)
117 | subject := fmt.Sprintf("%s密码重置", common.SystemName)
118 | content := fmt.Sprintf("您好,你正在进行%s密码重置。
"+
119 | "点击此处进行密码重置。
"+
120 | "重置链接 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, link, common.VerificationValidMinutes)
121 | err := common.SendEmail(subject, email, content)
122 | if err != nil {
123 | c.JSON(http.StatusOK, gin.H{
124 | "success": false,
125 | "message": err.Error(),
126 | })
127 | return
128 | }
129 | c.JSON(http.StatusOK, gin.H{
130 | "success": true,
131 | "message": "",
132 | })
133 | return
134 | }
135 |
136 | type PasswordResetRequest struct {
137 | Email string `json:"email"`
138 | Token string `json:"token"`
139 | }
140 |
141 | func ResetPassword(c *gin.Context) {
142 | var req PasswordResetRequest
143 | err := json.NewDecoder(c.Request.Body).Decode(&req)
144 | if req.Email == "" || req.Token == "" {
145 | c.JSON(http.StatusOK, gin.H{
146 | "success": false,
147 | "message": "无效的参数",
148 | })
149 | return
150 | }
151 | if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) {
152 | c.JSON(http.StatusOK, gin.H{
153 | "success": false,
154 | "message": "重置链接非法或已过期",
155 | })
156 | return
157 | }
158 | password := common.GenerateVerificationCode(12)
159 | err = model.ResetUserPasswordByEmail(req.Email, password)
160 | if err != nil {
161 | c.JSON(http.StatusOK, gin.H{
162 | "success": false,
163 | "message": err.Error(),
164 | })
165 | return
166 | }
167 | common.DeleteKey(req.Email, common.PasswordResetPurpose)
168 | c.JSON(http.StatusOK, gin.H{
169 | "success": true,
170 | "message": "",
171 | "data": password,
172 | })
173 | return
174 | }
175 |
--------------------------------------------------------------------------------
/controller/github.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "github.com/gin-contrib/sessions"
9 | "github.com/gin-gonic/gin"
10 | "message-pusher/common"
11 | "message-pusher/model"
12 | "net/http"
13 | "strconv"
14 | "time"
15 | )
16 |
17 | type GitHubOAuthResponse struct {
18 | AccessToken string `json:"access_token"`
19 | Scope string `json:"scope"`
20 | TokenType string `json:"token_type"`
21 | }
22 |
23 | type GitHubUser struct {
24 | Login string `json:"login"`
25 | Name string `json:"name"`
26 | Email string `json:"email"`
27 | }
28 |
29 | func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
30 | if code == "" {
31 | return nil, errors.New("无效的参数")
32 | }
33 | values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code}
34 | jsonData, err := json.Marshal(values)
35 | if err != nil {
36 | return nil, err
37 | }
38 | req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
39 | if err != nil {
40 | return nil, err
41 | }
42 | req.Header.Set("Content-Type", "application/json")
43 | req.Header.Set("Accept", "application/json")
44 | client := http.Client{
45 | Timeout: 5 * time.Second,
46 | }
47 | res, err := client.Do(req)
48 | if err != nil {
49 | common.SysLog(err.Error())
50 | return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
51 | }
52 | defer res.Body.Close()
53 | var oAuthResponse GitHubOAuthResponse
54 | err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
55 | if err != nil {
56 | return nil, err
57 | }
58 | req, err = http.NewRequest("GET", "https://api.github.com/user", nil)
59 | if err != nil {
60 | return nil, err
61 | }
62 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
63 | res2, err := client.Do(req)
64 | if err != nil {
65 | common.SysLog(err.Error())
66 | return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
67 | }
68 | defer res2.Body.Close()
69 | var githubUser GitHubUser
70 | err = json.NewDecoder(res2.Body).Decode(&githubUser)
71 | if err != nil {
72 | return nil, err
73 | }
74 | if githubUser.Login == "" {
75 | return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
76 | }
77 | return &githubUser, nil
78 | }
79 |
80 | func GitHubOAuth(c *gin.Context) {
81 | session := sessions.Default(c)
82 | username := session.Get("username")
83 | if username != nil {
84 | GitHubBind(c)
85 | return
86 | }
87 |
88 | if !common.GitHubOAuthEnabled {
89 | c.JSON(http.StatusOK, gin.H{
90 | "success": false,
91 | "message": "管理员未开启通过 GitHub 登录以及注册",
92 | })
93 | return
94 | }
95 | code := c.Query("code")
96 | githubUser, err := getGitHubUserInfoByCode(code)
97 | if err != nil {
98 | c.JSON(http.StatusOK, gin.H{
99 | "success": false,
100 | "message": err.Error(),
101 | })
102 | return
103 | }
104 | user := model.User{
105 | GitHubId: githubUser.Login,
106 | }
107 | if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
108 | err := user.FillUserByGitHubId()
109 | if err != nil {
110 | c.JSON(http.StatusOK, gin.H{
111 | "success": false,
112 | "message": err.Error(),
113 | })
114 | return
115 | }
116 | } else {
117 | if common.RegisterEnabled {
118 | user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
119 | if githubUser.Name != "" {
120 | user.DisplayName = githubUser.Name
121 | } else {
122 | user.DisplayName = "GitHub User"
123 | }
124 | user.Email = githubUser.Email
125 | user.Role = common.RoleCommonUser
126 | user.Status = common.UserStatusEnabled
127 |
128 | if err := user.Insert(); err != nil {
129 | c.JSON(http.StatusOK, gin.H{
130 | "success": false,
131 | "message": err.Error(),
132 | })
133 | return
134 | }
135 | } else {
136 | c.JSON(http.StatusOK, gin.H{
137 | "success": false,
138 | "message": "管理员关闭了新用户注册",
139 | })
140 | return
141 | }
142 | }
143 |
144 | if user.Status != common.UserStatusEnabled {
145 | c.JSON(http.StatusOK, gin.H{
146 | "message": "用户已被封禁",
147 | "success": false,
148 | })
149 | return
150 | }
151 | setupLogin(&user, c)
152 | }
153 |
154 | func GitHubBind(c *gin.Context) {
155 | if !common.GitHubOAuthEnabled {
156 | c.JSON(http.StatusOK, gin.H{
157 | "success": false,
158 | "message": "管理员未开启通过 GitHub 登录以及注册",
159 | })
160 | return
161 | }
162 | code := c.Query("code")
163 | githubUser, err := getGitHubUserInfoByCode(code)
164 | if err != nil {
165 | c.JSON(http.StatusOK, gin.H{
166 | "success": false,
167 | "message": err.Error(),
168 | })
169 | return
170 | }
171 | user := model.User{
172 | GitHubId: githubUser.Login,
173 | }
174 | if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
175 | c.JSON(http.StatusOK, gin.H{
176 | "success": false,
177 | "message": "该 GitHub 账户已被绑定",
178 | })
179 | return
180 | }
181 | session := sessions.Default(c)
182 | id := session.Get("id")
183 | // id := c.GetInt("id") // critical bug!
184 | user.Id = id.(int)
185 | err = user.FillUserById()
186 | if err != nil {
187 | c.JSON(http.StatusOK, gin.H{
188 | "success": false,
189 | "message": err.Error(),
190 | })
191 | return
192 | }
193 | user.GitHubId = githubUser.Login
194 | err = user.Update(false)
195 | if err != nil {
196 | c.JSON(http.StatusOK, gin.H{
197 | "success": false,
198 | "message": err.Error(),
199 | })
200 | return
201 | }
202 | c.JSON(http.StatusOK, gin.H{
203 | "success": true,
204 | "message": "bind",
205 | })
206 | return
207 | }
208 |
--------------------------------------------------------------------------------
/web/src/pages/Webhook/EditWebhook.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
3 | import { useParams } from 'react-router-dom';
4 | import { API, showError, showSuccess, verifyJSON } from '../../helpers';
5 | import { loadUserChannels } from '../../helpers/loader';
6 |
7 | const EditWebhook = () => {
8 | const params = useParams();
9 | const webhookId = params.id;
10 | const isEditing = webhookId !== undefined;
11 | const [loading, setLoading] = useState(isEditing);
12 | const originInputs = {
13 | name: '',
14 | extract_rule: `{
15 | "title": "attr1",
16 | "description": "attr2.sub_attr",
17 | "content": "attr3",
18 | "url": "attr4"
19 | }`,
20 | construct_rule:
21 | '{\n' +
22 | ' "title": "$title",\n' +
23 | ' "description": "描述信息:$description",\n' +
24 | ' "content": "内容:$content",\n' +
25 | ' "url": "https://example.com/$title"\n' +
26 | '}',
27 | channel: 'default',
28 | };
29 |
30 | const [inputs, setInputs] = useState(originInputs);
31 | const { name, extract_rule, construct_rule, channel } = inputs;
32 | let [channels, setChannels] = useState([]);
33 |
34 | const handleInputChange = (e, { name, value }) => {
35 | setInputs((inputs) => ({ ...inputs, [name]: value }));
36 | };
37 |
38 | const loadWebhook = async () => {
39 | let res = await API.get(`/api/webhook/${webhookId}`);
40 | const { success, message, data } = res.data;
41 | if (success) {
42 | if (data.channel === '') {
43 | data.channel = 'default';
44 | }
45 | setInputs(data);
46 | } else {
47 | showError(message);
48 | }
49 | setLoading(false);
50 | };
51 |
52 | useEffect(() => {
53 | const loader = async () => {
54 | if (isEditing) {
55 | loadWebhook().then();
56 | }
57 | let channels = await loadUserChannels();
58 | if (channels) {
59 | channels.unshift({
60 | key: 'default',
61 | text: '默认通道',
62 | value: 'default',
63 | description: '使用默认通道',
64 | });
65 | setChannels(channels);
66 | }
67 | };
68 | loader().then();
69 | }, []);
70 |
71 | const submit = async () => {
72 | if (!name) return;
73 | if (!verifyJSON(extract_rule)) {
74 | showError('提取规则不是合法的 JSON 格式!');
75 | return;
76 | }
77 | if (!verifyJSON(construct_rule)) {
78 | showError('构造规则不是合法的 JSON 格式!');
79 | return;
80 | }
81 | let res = undefined;
82 | let localInputs = { ...inputs };
83 | if (localInputs.channel === 'default') {
84 | localInputs.channel = '';
85 | }
86 | if (isEditing) {
87 | res = await API.put(`/api/webhook/`, {
88 | ...localInputs,
89 | id: parseInt(webhookId),
90 | });
91 | } else {
92 | res = await API.post(`/api/webhook`, localInputs);
93 | }
94 | const { success, message } = res.data;
95 | if (success) {
96 | if (isEditing) {
97 | showSuccess('接口信息更新成功!');
98 | } else {
99 | showSuccess('接口创建成功!');
100 | setInputs(originInputs);
101 | }
102 | } else {
103 | showError(message);
104 | }
105 | };
106 |
107 | return (
108 | <>
109 |
110 | {isEditing ? '更新接口配置' : '新建消息接口'}
111 |
113 |
121 |
122 |
123 |
134 |
135 |
136 | 如果你不知道如何写提取规则和构建规则,请看
137 |
141 | 此教程
142 |
143 | 。
144 |
145 |
146 |
154 |
155 |
156 |
164 |
165 |
166 |
167 |
168 | >
169 | );
170 | };
171 |
172 | export default EditWebhook;
173 |
--------------------------------------------------------------------------------
/model/option.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "message-pusher/common"
5 | "strconv"
6 | "strings"
7 | )
8 |
9 | type Option struct {
10 | Key string `json:"key" gorm:"primaryKey"`
11 | Value string `json:"value"`
12 | }
13 |
14 | func AllOption() ([]*Option, error) {
15 | var options []*Option
16 | var err error
17 | err = DB.Find(&options).Error
18 | return options, err
19 | }
20 |
21 | func InitOptionMap() {
22 | common.OptionMapRWMutex.Lock()
23 | common.OptionMap = make(map[string]string)
24 | common.OptionMap["FileUploadPermission"] = strconv.Itoa(common.FileUploadPermission)
25 | common.OptionMap["FileDownloadPermission"] = strconv.Itoa(common.FileDownloadPermission)
26 | common.OptionMap["ImageUploadPermission"] = strconv.Itoa(common.ImageUploadPermission)
27 | common.OptionMap["ImageDownloadPermission"] = strconv.Itoa(common.ImageDownloadPermission)
28 | common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled)
29 | common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
30 | common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
31 | common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
32 | common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
33 | common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
34 | common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
35 | common.OptionMap["MessagePersistenceEnabled"] = strconv.FormatBool(common.MessagePersistenceEnabled)
36 | common.OptionMap["MessageRenderEnabled"] = strconv.FormatBool(common.MessageRenderEnabled)
37 | common.OptionMap["SMTPServer"] = ""
38 | common.OptionMap["SMTPAccount"] = ""
39 | common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
40 | common.OptionMap["SMTPToken"] = ""
41 | common.OptionMap["Notice"] = ""
42 | common.OptionMap["About"] = ""
43 | common.OptionMap["Footer"] = common.Footer
44 | common.OptionMap["HomePageLink"] = common.HomePageLink
45 | common.OptionMap["ServerAddress"] = ""
46 | common.OptionMap["GitHubClientId"] = ""
47 | common.OptionMap["GitHubClientSecret"] = ""
48 | common.OptionMap["WeChatServerAddress"] = ""
49 | common.OptionMap["WeChatServerToken"] = ""
50 | common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
51 | common.OptionMap["TurnstileSiteKey"] = ""
52 | common.OptionMap["TurnstileSecretKey"] = ""
53 | common.OptionMapRWMutex.Unlock()
54 | options, _ := AllOption()
55 | for _, option := range options {
56 | updateOptionMap(option.Key, option.Value)
57 | }
58 | }
59 |
60 | func UpdateOption(key string, value string) error {
61 | // Save to database first
62 | option := Option{
63 | Key: key,
64 | }
65 | // https://gorm.io/docs/update.html#Save-All-Fields
66 | DB.FirstOrCreate(&option, Option{Key: key})
67 | option.Value = value
68 | // Save is a combination function.
69 | // If save value does not contain primary key, it will execute Create,
70 | // otherwise it will execute Update (with all fields).
71 | DB.Save(&option)
72 | // Update OptionMap
73 | updateOptionMap(key, value)
74 | return nil
75 | }
76 |
77 | func updateOptionMap(key string, value string) {
78 | common.OptionMapRWMutex.Lock()
79 | defer common.OptionMapRWMutex.Unlock()
80 | common.OptionMap[key] = value
81 | if strings.HasSuffix(key, "Permission") {
82 | intValue, _ := strconv.Atoi(value)
83 | switch key {
84 | case "FileUploadPermission":
85 | common.FileUploadPermission = intValue
86 | case "FileDownloadPermission":
87 | common.FileDownloadPermission = intValue
88 | case "ImageUploadPermission":
89 | common.ImageUploadPermission = intValue
90 | case "ImageDownloadPermission":
91 | common.ImageDownloadPermission = intValue
92 | }
93 | }
94 | if strings.HasSuffix(key, "Enabled") {
95 | boolValue := value == "true"
96 | switch key {
97 | case "PasswordRegisterEnabled":
98 | common.PasswordRegisterEnabled = boolValue
99 | case "PasswordLoginEnabled":
100 | common.PasswordLoginEnabled = boolValue
101 | case "EmailVerificationEnabled":
102 | common.EmailVerificationEnabled = boolValue
103 | case "GitHubOAuthEnabled":
104 | common.GitHubOAuthEnabled = boolValue
105 | case "WeChatAuthEnabled":
106 | common.WeChatAuthEnabled = boolValue
107 | case "TurnstileCheckEnabled":
108 | common.TurnstileCheckEnabled = boolValue
109 | case "RegisterEnabled":
110 | common.RegisterEnabled = boolValue
111 | case "MessagePersistenceEnabled":
112 | common.MessagePersistenceEnabled = boolValue
113 | case "MessageRenderEnabled":
114 | common.MessageRenderEnabled = boolValue
115 | }
116 | }
117 | switch key {
118 | case "SMTPServer":
119 | common.SMTPServer = value
120 | case "SMTPPort":
121 | intValue, _ := strconv.Atoi(value)
122 | common.SMTPPort = intValue
123 | case "SMTPAccount":
124 | common.SMTPAccount = value
125 | case "SMTPToken":
126 | common.SMTPToken = value
127 | case "ServerAddress":
128 | common.ServerAddress = value
129 | case "GitHubClientId":
130 | common.GitHubClientId = value
131 | case "GitHubClientSecret":
132 | common.GitHubClientSecret = value
133 | case "Footer":
134 | common.Footer = value
135 | case "HomePageLink":
136 | common.HomePageLink = value
137 | case "WeChatServerAddress":
138 | common.WeChatServerAddress = value
139 | case "WeChatServerToken":
140 | common.WeChatServerToken = value
141 | case "WeChatAccountQRCodeImageURL":
142 | common.WeChatAccountQRCodeImageURL = value
143 | case "TurnstileSiteKey":
144 | common.TurnstileSiteKey = value
145 | case "TurnstileSecretKey":
146 | common.TurnstileSecretKey = value
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/controller/channel.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "message-pusher/channel"
5 | "message-pusher/common"
6 | "message-pusher/model"
7 | "net/http"
8 | "strconv"
9 |
10 | "github.com/gin-gonic/gin"
11 | )
12 |
13 | func GetAllChannels(c *gin.Context) {
14 | if c.Query("brief") != "" {
15 | GetBriefChannels(c)
16 | return
17 | }
18 | userId := c.GetInt("id")
19 | p, _ := strconv.Atoi(c.Query("p"))
20 | if p < 0 {
21 | p = 0
22 | }
23 | channels, err := model.GetChannelsByUserId(userId, p*common.ItemsPerPage, common.ItemsPerPage)
24 | if err != nil {
25 | c.JSON(http.StatusOK, gin.H{
26 | "success": false,
27 | "message": err.Error(),
28 | })
29 | return
30 | }
31 | c.JSON(http.StatusOK, gin.H{
32 | "success": true,
33 | "message": "",
34 | "data": channels,
35 | })
36 | return
37 | }
38 |
39 | func GetBriefChannels(c *gin.Context) {
40 | userId := c.GetInt("id")
41 | channels, err := model.GetBriefChannelsByUserId(userId)
42 | if err != nil {
43 | c.JSON(http.StatusOK, gin.H{
44 | "success": false,
45 | "message": err.Error(),
46 | })
47 | return
48 | }
49 | c.JSON(http.StatusOK, gin.H{
50 | "success": true,
51 | "message": "",
52 | "data": channels,
53 | })
54 | return
55 | }
56 |
57 | func SearchChannels(c *gin.Context) {
58 | userId := c.GetInt("id")
59 | keyword := c.Query("keyword")
60 | channels, err := model.SearchChannels(userId, keyword)
61 | if err != nil {
62 | c.JSON(http.StatusOK, gin.H{
63 | "success": false,
64 | "message": err.Error(),
65 | })
66 | return
67 | }
68 | c.JSON(http.StatusOK, gin.H{
69 | "success": true,
70 | "message": "",
71 | "data": channels,
72 | })
73 | return
74 | }
75 |
76 | func GetChannel(c *gin.Context) {
77 | id, err := strconv.Atoi(c.Param("id"))
78 | userId := c.GetInt("id")
79 | if err != nil {
80 | c.JSON(http.StatusOK, gin.H{
81 | "success": false,
82 | "message": err.Error(),
83 | })
84 | return
85 | }
86 | channel_, err := model.GetChannelById(id, userId, false)
87 | if err != nil {
88 | c.JSON(http.StatusOK, gin.H{
89 | "success": false,
90 | "message": err.Error(),
91 | })
92 | return
93 | }
94 | c.JSON(http.StatusOK, gin.H{
95 | "success": true,
96 | "message": "",
97 | "data": channel_,
98 | })
99 | return
100 | }
101 |
102 | func AddChannel(c *gin.Context) {
103 | channel_ := model.Channel{}
104 | err := c.ShouldBindJSON(&channel_)
105 | if err != nil {
106 | c.JSON(http.StatusOK, gin.H{
107 | "success": false,
108 | "message": err.Error(),
109 | })
110 | return
111 | }
112 | if len(channel_.Name) == 0 || len(channel_.Name) > 20 {
113 | c.JSON(http.StatusOK, gin.H{
114 | "success": false,
115 | "message": "通道名称长度必须在1-20之间",
116 | })
117 | return
118 | }
119 | if channel_.Name == "email" {
120 | c.JSON(http.StatusOK, gin.H{
121 | "success": false,
122 | "message": "不能使用系统保留名称",
123 | })
124 | return
125 | }
126 | cleanChannel := model.Channel{
127 | Type: channel_.Type,
128 | UserId: c.GetInt("id"),
129 | Name: channel_.Name,
130 | Description: channel_.Description,
131 | Status: common.ChannelStatusEnabled,
132 | Secret: channel_.Secret,
133 | AppId: channel_.AppId,
134 | AccountId: channel_.AccountId,
135 | URL: channel_.URL,
136 | Other: channel_.Other,
137 | CreatedTime: common.GetTimestamp(),
138 | Token: channel_.Token,
139 | }
140 | err = cleanChannel.Insert()
141 | if err != nil {
142 | c.JSON(http.StatusOK, gin.H{
143 | "success": false,
144 | "message": err.Error(),
145 | })
146 | return
147 | }
148 | channel.TokenStoreAddChannel(&cleanChannel)
149 | c.JSON(http.StatusOK, gin.H{
150 | "success": true,
151 | "message": "",
152 | })
153 | return
154 | }
155 |
156 | func DeleteChannel(c *gin.Context) {
157 | id, _ := strconv.Atoi(c.Param("id"))
158 | userId := c.GetInt("id")
159 | channel_, err := model.DeleteChannelById(id, userId)
160 | if err != nil {
161 | c.JSON(http.StatusOK, gin.H{
162 | "success": false,
163 | "message": err.Error(),
164 | })
165 | return
166 | }
167 | channel.TokenStoreRemoveChannel(channel_)
168 | c.JSON(http.StatusOK, gin.H{
169 | "success": true,
170 | "message": "",
171 | })
172 | return
173 | }
174 |
175 | func UpdateChannel(c *gin.Context) {
176 | userId := c.GetInt("id")
177 | statusOnly := c.Query("status_only")
178 | channel_ := model.Channel{}
179 | err := c.ShouldBindJSON(&channel_)
180 | if err != nil {
181 | c.JSON(http.StatusOK, gin.H{
182 | "success": false,
183 | "message": err.Error(),
184 | })
185 | return
186 | }
187 | oldChannel, err := model.GetChannelById(channel_.Id, userId, true)
188 | if err != nil {
189 | c.JSON(http.StatusOK, gin.H{
190 | "success": false,
191 | "message": err.Error(),
192 | })
193 | return
194 | }
195 | cleanChannel := *oldChannel
196 | if statusOnly != "" {
197 | cleanChannel.Status = channel_.Status
198 | } else {
199 | // If you add more fields, please also update channel_.Update()
200 | cleanChannel.Type = channel_.Type
201 | cleanChannel.Name = channel_.Name
202 | cleanChannel.Description = channel_.Description
203 | if channel_.Secret != "" {
204 | cleanChannel.Secret = channel_.Secret
205 | }
206 | cleanChannel.AppId = channel_.AppId
207 | cleanChannel.AccountId = channel_.AccountId
208 | cleanChannel.URL = channel_.URL
209 | cleanChannel.Other = channel_.Other
210 | cleanChannel.Token = channel_.Token
211 | }
212 | err = cleanChannel.Update()
213 | if err != nil {
214 | c.JSON(http.StatusOK, gin.H{
215 | "success": false,
216 | "message": err.Error(),
217 | })
218 | return
219 | }
220 | channel.TokenStoreUpdateChannel(&cleanChannel, oldChannel)
221 | c.JSON(http.StatusOK, gin.H{
222 | "success": true,
223 | "message": "",
224 | "data": cleanChannel,
225 | })
226 | return
227 | }
228 |
--------------------------------------------------------------------------------
/web/src/components/OtherSetting.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Button, Divider, Form, Grid, Header, Modal } from 'semantic-ui-react';
3 | import { API, showError, showSuccess } from '../helpers';
4 | import { marked } from 'marked';
5 |
6 | const OtherSetting = () => {
7 | let [inputs, setInputs] = useState({
8 | Footer: '',
9 | Notice: '',
10 | About: '',
11 | HomePageLink: '',
12 | });
13 | let [loading, setLoading] = useState(false);
14 | const [showUpdateModal, setShowUpdateModal] = useState(false);
15 | const [updateData, setUpdateData] = useState({
16 | tag_name: '',
17 | content: '',
18 | });
19 |
20 | const getOptions = async () => {
21 | const res = await API.get('/api/option/');
22 | const { success, message, data } = res.data;
23 | if (success) {
24 | let newInputs = {};
25 | data.forEach((item) => {
26 | if (item.key in inputs) {
27 | newInputs[item.key] = item.value;
28 | }
29 | });
30 | setInputs(newInputs);
31 | } else {
32 | showError(message);
33 | }
34 | };
35 |
36 | useEffect(() => {
37 | getOptions().then();
38 | }, []);
39 |
40 | const updateOption = async (key, value) => {
41 | setLoading(true);
42 | const res = await API.put('/api/option/', {
43 | key,
44 | value,
45 | });
46 | const { success, message } = res.data;
47 | if (success) {
48 | setInputs((inputs) => ({ ...inputs, [key]: value }));
49 | } else {
50 | showError(message);
51 | }
52 | setLoading(false);
53 | };
54 |
55 | const handleInputChange = async (e, { name, value }) => {
56 | setInputs((inputs) => ({ ...inputs, [name]: value }));
57 | };
58 |
59 | const submitNotice = async () => {
60 | await updateOption('Notice', inputs.Notice);
61 | };
62 |
63 | const submitFooter = async () => {
64 | await updateOption('Footer', inputs.Footer);
65 | };
66 |
67 | const submitHomePageLink = async () => {
68 | await updateOption('HomePageLink', inputs.HomePageLink);
69 | };
70 |
71 | const submitAbout = async () => {
72 | await updateOption('About', inputs.About);
73 | };
74 |
75 | const openGitHubRelease = () => {
76 | window.location =
77 | 'https://github.com/songquanpeng/message-pusher/releases/latest';
78 | };
79 |
80 | const checkUpdate = async () => {
81 | const res = await API.get(
82 | 'https://api.github.com/repos/songquanpeng/message-pusher/releases/latest'
83 | );
84 | const { tag_name, body } = res.data;
85 | if (tag_name === process.env.REACT_APP_VERSION) {
86 | showSuccess(`已是最新版本:${tag_name}`);
87 | } else {
88 | setUpdateData({
89 | tag_name: tag_name,
90 | content: marked.parse(body),
91 | });
92 | setShowUpdateModal(true);
93 | }
94 | };
95 |
96 | return (
97 |
98 |
99 | 检查更新
102 |
103 |
111 |
112 | 保存公告
113 |
114 |
115 |
116 |
124 |
125 | 设置首页链接
126 |
127 |
135 |
136 | 保存关于
137 |
138 |
145 |
146 | 设置页脚
147 |
148 |
149 | setShowUpdateModal(false)}
151 | onOpen={() => setShowUpdateModal(true)}
152 | open={showUpdateModal}
153 | >
154 | 新版本:{updateData.tag_name}
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
170 |
171 |
172 | );
173 | };
174 |
175 | export default OtherSetting;
176 |
--------------------------------------------------------------------------------
/web/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from 'react';
2 | import { Link, useNavigate } from 'react-router-dom';
3 | import { UserContext } from '../context/User';
4 |
5 | import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react';
6 | import { API, isAdmin, isMobile, showSuccess } from '../helpers';
7 | import '../index.css';
8 |
9 | // Header Buttons
10 | const headerButtons = [
11 | {
12 | name: '首页',
13 | to: '/',
14 | icon: 'home',
15 | },
16 | {
17 | name: '消息',
18 | to: '/message',
19 | icon: 'mail',
20 | },
21 | {
22 | name: '编辑',
23 | to: '/editor',
24 | icon: 'edit',
25 | },
26 | {
27 | name: '通道',
28 | to: '/channel',
29 | icon: 'sitemap',
30 | },
31 | {
32 | name: '接口',
33 | to: '/webhook',
34 | icon: 'code',
35 | },
36 | {
37 | name: '用户',
38 | to: '/user',
39 | icon: 'user',
40 | admin: true,
41 | },
42 | {
43 | name: '设置',
44 | to: '/setting',
45 | icon: 'setting',
46 | },
47 | {
48 | name: '关于',
49 | to: '/about',
50 | icon: 'info circle',
51 | },
52 | ];
53 |
54 | const Header = () => {
55 | const [userState, userDispatch] = useContext(UserContext);
56 | let navigate = useNavigate();
57 |
58 | const [showSidebar, setShowSidebar] = useState(false);
59 |
60 | async function logout() {
61 | setShowSidebar(false);
62 | await API.get('/api/user/logout');
63 | showSuccess('注销成功!');
64 | userDispatch({ type: 'logout' });
65 | localStorage.removeItem('user');
66 | navigate('/login');
67 | }
68 |
69 | const toggleSidebar = () => {
70 | setShowSidebar(!showSidebar);
71 | };
72 |
73 | const renderButtons = (isMobile) => {
74 | return headerButtons.map((button) => {
75 | if (button.admin && !isAdmin()) return <>>;
76 | if (isMobile) {
77 | return (
78 | {
80 | navigate(button.to);
81 | setShowSidebar(false);
82 | }}
83 | >
84 | {button.name}
85 |
86 | );
87 | }
88 | return (
89 |
90 |
91 | {button.name}
92 |
93 | );
94 | });
95 | };
96 |
97 | if (isMobile()) {
98 | return (
99 | <>
100 |
132 | {showSidebar ? (
133 |
134 |
161 |
162 | ) : (
163 | <>>
164 | )}
165 | >
166 | );
167 | }
168 |
169 | return (
170 | <>
171 |
202 | >
203 | );
204 | };
205 |
206 | export default Header;
207 |
--------------------------------------------------------------------------------