update at 2026-04-21 17:55:41
This commit is contained in:
28
server/README.md
Normal file
28
server/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# note2any-wx-server
|
||||
|
||||
仓库内置的 TypeScript 版微信代理服务。
|
||||
|
||||
## 接口
|
||||
|
||||
- `GET /`
|
||||
- `POST /v1/wx/token`
|
||||
- `POST /v1/wx/encrypt`
|
||||
- `GET /v1/wx/info/:authkey`
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `PORT`,默认 `3001`
|
||||
- `ENCRYPT_KEY`,长度必须为 `32`
|
||||
- `USERS_FILE`,默认 `./data/users.json`
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build:server
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
node server/dist/index.js
|
||||
```
|
||||
39
server/deploy.sh
Normal file
39
server/deploy.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="gavin@biboer.cn"
|
||||
PORT="21174"
|
||||
REMOTE_DIR="/home/gavin/note2any-wx-server"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
npm run build:server
|
||||
|
||||
ssh -p "$PORT" "$REMOTE" bash -s <<EOF
|
||||
set -euo pipefail
|
||||
mkdir -p "$REMOTE_DIR/backups/$STAMP"
|
||||
cp -f "$REMOTE_DIR/app.js" "$REMOTE_DIR/backups/$STAMP/app.js.bak" 2>/dev/null || true
|
||||
cp -f "$REMOTE_DIR/package.json" "$REMOTE_DIR/backups/$STAMP/package.json.bak" 2>/dev/null || true
|
||||
cp -f "$REMOTE_DIR/ecosystem.config.js" "$REMOTE_DIR/backups/$STAMP/ecosystem.config.js.bak" 2>/dev/null || true
|
||||
EOF
|
||||
|
||||
scp -P "$PORT" -r \
|
||||
"server/dist" \
|
||||
"server/package.json" \
|
||||
"server/README.md" \
|
||||
"$REMOTE:$REMOTE_DIR/"
|
||||
|
||||
ssh -p "$PORT" "$REMOTE" bash -s <<EOF
|
||||
set -euo pipefail
|
||||
cat > "$REMOTE_DIR/app.js" <<'APP'
|
||||
require("./dist/index.js");
|
||||
APP
|
||||
pkill -f "node $REMOTE_DIR/app.js" 2>/dev/null || true
|
||||
pkill -f "node $REMOTE_DIR/dist/index.js" 2>/dev/null || true
|
||||
cd "$REMOTE_DIR"
|
||||
set -a
|
||||
[ -f ./.env ] && . ./.env
|
||||
set +a
|
||||
nohup node app.js > server.log 2>&1 &
|
||||
sleep 1
|
||||
ps -ef | grep -E "node .*note2any-wx-server/(app|dist/index)\\.js" | grep -v grep
|
||||
EOF
|
||||
63
server/dist/config.js
vendored
Normal file
63
server/dist/config.js
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.config = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
function readDotEnv(dotEnvPath) {
|
||||
if (!fs.existsSync(dotEnvPath)) {
|
||||
return {};
|
||||
}
|
||||
const content = fs.readFileSync(dotEnvPath, "utf8");
|
||||
const parsed = {};
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (line.length === 0 || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const index = line.indexOf("=");
|
||||
if (index <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = line.slice(0, index).trim();
|
||||
const value = line.slice(index + 1).trim();
|
||||
if (key.length > 0 && !(key in parsed)) {
|
||||
parsed[key] = value;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
const cwd = process.cwd();
|
||||
const dotEnv = readDotEnv(path.join(cwd, ".env"));
|
||||
function getEnv(name, fallback = "") {
|
||||
var _a, _b;
|
||||
const value = (_b = (_a = process.env[name]) !== null && _a !== void 0 ? _a : dotEnv[name]) !== null && _b !== void 0 ? _b : fallback;
|
||||
return value.trim();
|
||||
}
|
||||
exports.config = {
|
||||
port: parseInt(getEnv("PORT", "3001"), 10),
|
||||
encryptKey: getEnv("ENCRYPT_KEY"),
|
||||
usersFile: path.resolve(cwd, getEnv("USERS_FILE", "./data/users.json")),
|
||||
};
|
||||
53
server/dist/crypto.js
vendored
Normal file
53
server/dist/crypto.js
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.decrypt = exports.encrypt = void 0;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const config_1 = require("./config");
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const IV_LENGTH = 16;
|
||||
function getKey() {
|
||||
if (config_1.config.encryptKey.length !== 32) {
|
||||
throw new Error("ENCRYPT_KEY must be a 32-character string");
|
||||
}
|
||||
return Buffer.from(config_1.config.encryptKey, "utf8");
|
||||
}
|
||||
function encrypt(text) {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
|
||||
return iv.toString("hex") + encrypted.toString("hex");
|
||||
}
|
||||
exports.encrypt = encrypt;
|
||||
function decrypt(encrypted) {
|
||||
const ivHex = encrypted.slice(0, IV_LENGTH * 2);
|
||||
const contentHex = encrypted.slice(IV_LENGTH * 2);
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const content = Buffer.from(contentHex, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
exports.decrypt = decrypt;
|
||||
31
server/dist/http.js
vendored
Normal file
31
server/dist/http.js
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.readJsonBody = exports.sendJson = void 0;
|
||||
function sendJson(res, statusCode, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
exports.sendJson = sendJson;
|
||||
async function readJsonBody(req, limitBytes = 10 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.length;
|
||||
if (size > limitBytes) {
|
||||
throw new Error("Request body too large");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (raw.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
exports.readJsonBody = readJsonBody;
|
||||
127
server/dist/index.js
vendored
Normal file
127
server/dist/index.js
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const http = __importStar(require("http"));
|
||||
const url_1 = require("url");
|
||||
const config_1 = require("./config");
|
||||
const crypto_1 = require("./crypto");
|
||||
const http_1 = require("./http");
|
||||
const wechat_1 = require("./wechat");
|
||||
const tokenCache = {};
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
async function handleToken(req, res) {
|
||||
var _a;
|
||||
const body = await (0, http_1.readJsonBody)(req);
|
||||
const appid = typeof body.appid === "string" ? body.appid.trim() : "";
|
||||
const secret = typeof body.secret === "string" ? body.secret.trim() : "";
|
||||
if (!appid || !secret) {
|
||||
(0, http_1.sendJson)(res, 400, { code: 400, msg: "Missing required fields" });
|
||||
return;
|
||||
}
|
||||
const cached = tokenCache[appid];
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
(0, http_1.sendJson)(res, 200, {
|
||||
access_token: cached.token,
|
||||
expires_in: Math.floor((cached.expiresAt - Date.now()) / 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const realSecret = secret.startsWith("SECRET") ? (0, crypto_1.decrypt)(secret.slice(6)) : secret;
|
||||
const wxUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appid)}&secret=${encodeURIComponent(realSecret)}`;
|
||||
const wxRes = await (0, wechat_1.httpsGetJson)(wxUrl);
|
||||
if (typeof wxRes.errcode === "number") {
|
||||
(0, http_1.sendJson)(res, 200, { code: wxRes.errcode, msg: (_a = wxRes.errmsg) !== null && _a !== void 0 ? _a : "" });
|
||||
return;
|
||||
}
|
||||
if (!isNonEmptyString(wxRes.access_token) || typeof wxRes.expires_in !== "number") {
|
||||
throw new Error("Invalid token response from WeChat API");
|
||||
}
|
||||
tokenCache[appid] = {
|
||||
token: wxRes.access_token,
|
||||
expiresAt: Date.now() + 7000 * 1000,
|
||||
};
|
||||
(0, http_1.sendJson)(res, 200, { ...wxRes });
|
||||
}
|
||||
async function handleEncrypt(req, res) {
|
||||
const body = await (0, http_1.readJsonBody)(req);
|
||||
const wechat = Array.isArray(body.wechat) ? body.wechat : [];
|
||||
if (wechat.length === 0) {
|
||||
(0, http_1.sendJson)(res, 400, { code: 400, msg: "Missing required fields" });
|
||||
return;
|
||||
}
|
||||
const encrypted = wechat.map((item) => ({
|
||||
name: typeof item.name === "string" ? item.name : "",
|
||||
appid: typeof item.appid === "string" ? item.appid : "",
|
||||
secret: "SECRET" + (0, crypto_1.encrypt)(typeof item.secret === "string" ? item.secret : ""),
|
||||
}));
|
||||
(0, http_1.sendJson)(res, 200, { code: 0, wechat: encrypted });
|
||||
}
|
||||
function handleInfo(authkey, res) {
|
||||
(0, http_1.sendJson)(res, 200, {
|
||||
code: 0,
|
||||
name: authkey || "note2any",
|
||||
status: "active",
|
||||
created: "",
|
||||
});
|
||||
}
|
||||
async function requestHandler(req, res) {
|
||||
var _a, _b;
|
||||
const method = (_a = req.method) !== null && _a !== void 0 ? _a : "GET";
|
||||
const url = new url_1.URL((_b = req.url) !== null && _b !== void 0 ? _b : "/", "http://127.0.0.1");
|
||||
const pathname = url.pathname;
|
||||
try {
|
||||
if (method === "GET" && pathname === "/") {
|
||||
(0, http_1.sendJson)(res, 200, { status: "ok", service: "note2any-wx-server" });
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && pathname === "/v1/wx/token") {
|
||||
await handleToken(req, res);
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && pathname === "/v1/wx/encrypt") {
|
||||
await handleEncrypt(req, res);
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && pathname.startsWith("/v1/wx/info/")) {
|
||||
const authkey = decodeURIComponent(pathname.slice("/v1/wx/info/".length));
|
||||
handleInfo(authkey, res);
|
||||
return;
|
||||
}
|
||||
(0, http_1.sendJson)(res, 404, { code: 404, msg: "Not Found" });
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown server error";
|
||||
console.error(`[server] ${method} ${pathname} failed: ${message}`);
|
||||
(0, http_1.sendJson)(res, 500, { code: 500, msg: message });
|
||||
}
|
||||
}
|
||||
const server = http.createServer((req, res) => {
|
||||
void requestHandler(req, res);
|
||||
});
|
||||
server.listen(config_1.config.port, () => {
|
||||
console.log(`note2any-wx-server running on port ${config_1.config.port}`);
|
||||
});
|
||||
44
server/dist/users.js
vendored
Normal file
44
server/dist/users.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.validateAuthKey = void 0;
|
||||
const fs = __importStar(require("fs"));
|
||||
const config_1 = require("./config");
|
||||
function readUsersFile() {
|
||||
const content = fs.readFileSync(config_1.config.usersFile, "utf8");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
function validateAuthKey(authkey) {
|
||||
const users = readUsersFile().users;
|
||||
const user = users[authkey];
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
if (user.status !== "active") {
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
exports.validateAuthKey = validateAuthKey;
|
||||
46
server/dist/wechat.js
vendored
Normal file
46
server/dist/wechat.js
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.httpsGetJson = void 0;
|
||||
const https = __importStar(require("https"));
|
||||
function httpsGetJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
}
|
||||
catch (_error) {
|
||||
reject(new Error("Invalid JSON from WeChat API"));
|
||||
}
|
||||
});
|
||||
}).on("error", reject);
|
||||
});
|
||||
}
|
||||
exports.httpsGetJson = httpsGetJson;
|
||||
10
server/package.json
Normal file
10
server/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "note2any-wx-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "TypeScript implementation of the note2any WeChat proxy server",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
}
|
||||
}
|
||||
45
server/src/config.ts
Normal file
45
server/src/config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
function readDotEnv(dotEnvPath: string): Record<string, string> {
|
||||
if (!fs.existsSync(dotEnvPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(dotEnvPath, "utf8");
|
||||
const parsed: Record<string, string> = {};
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (line.length === 0 || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = line.indexOf("=");
|
||||
if (index <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, index).trim();
|
||||
const value = line.slice(index + 1).trim();
|
||||
if (key.length > 0 && !(key in parsed)) {
|
||||
parsed[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const dotEnv = readDotEnv(path.join(cwd, ".env"));
|
||||
|
||||
function getEnv(name: string, fallback = ""): string {
|
||||
const value = process.env[name] ?? dotEnv[name] ?? fallback;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: parseInt(getEnv("PORT", "3001"), 10),
|
||||
encryptKey: getEnv("ENCRYPT_KEY"),
|
||||
usersFile: path.resolve(cwd, getEnv("USERS_FILE", "./data/users.json")),
|
||||
};
|
||||
30
server/src/crypto.ts
Normal file
30
server/src/crypto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as crypto from "crypto";
|
||||
import { config } from "./config";
|
||||
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
if (config.encryptKey.length !== 32) {
|
||||
throw new Error("ENCRYPT_KEY must be a 32-character string");
|
||||
}
|
||||
|
||||
return Buffer.from(config.encryptKey, "utf8");
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
|
||||
return iv.toString("hex") + encrypted.toString("hex");
|
||||
}
|
||||
|
||||
export function decrypt(encrypted: string): string {
|
||||
const ivHex = encrypted.slice(0, IV_LENGTH * 2);
|
||||
const contentHex = encrypted.slice(IV_LENGTH * 2);
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const content = Buffer.from(contentHex, "hex");
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
36
server/src/http.ts
Normal file
36
server/src/http.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as http from "http";
|
||||
|
||||
export interface JsonResponse {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function sendJson(res: http.ServerResponse, statusCode: number, payload: JsonResponse): void {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export async function readJsonBody(req: http.IncomingMessage, limitBytes = 10 * 1024 * 1024): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.length;
|
||||
if (size > limitBytes) {
|
||||
throw new Error("Request body too large");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (raw.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as unknown;
|
||||
}
|
||||
139
server/src/index.ts
Normal file
139
server/src/index.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as http from "http";
|
||||
import { URL } from "url";
|
||||
import { config } from "./config";
|
||||
import { decrypt, encrypt } from "./crypto";
|
||||
import { readJsonBody, sendJson } from "./http";
|
||||
import { httpsGetJson } from "./wechat";
|
||||
|
||||
interface TokenRequestBody {
|
||||
authkey?: string;
|
||||
appid?: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
interface EncryptWechatItem {
|
||||
name?: string;
|
||||
appid?: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
interface EncryptRequestBody {
|
||||
authkey?: string;
|
||||
wechat?: EncryptWechatItem[];
|
||||
}
|
||||
|
||||
const tokenCache: Record<string, { token: string; expiresAt: number }> = {};
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
async function handleToken(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const body = await readJsonBody(req) as TokenRequestBody;
|
||||
const appid = typeof body.appid === "string" ? body.appid.trim() : "";
|
||||
const secret = typeof body.secret === "string" ? body.secret.trim() : "";
|
||||
|
||||
if (!appid || !secret) {
|
||||
sendJson(res, 400, { code: 400, msg: "Missing required fields" });
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = tokenCache[appid];
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
sendJson(res, 200, {
|
||||
access_token: cached.token,
|
||||
expires_in: Math.floor((cached.expiresAt - Date.now()) / 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const realSecret = secret.startsWith("SECRET") ? decrypt(secret.slice(6)) : secret;
|
||||
const wxUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appid)}&secret=${encodeURIComponent(realSecret)}`;
|
||||
const wxRes = await httpsGetJson(wxUrl);
|
||||
|
||||
if (typeof wxRes.errcode === "number") {
|
||||
sendJson(res, 200, { code: wxRes.errcode, msg: wxRes.errmsg ?? "" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(wxRes.access_token) || typeof wxRes.expires_in !== "number") {
|
||||
throw new Error("Invalid token response from WeChat API");
|
||||
}
|
||||
|
||||
tokenCache[appid] = {
|
||||
token: wxRes.access_token,
|
||||
expiresAt: Date.now() + 7000 * 1000,
|
||||
};
|
||||
|
||||
sendJson(res, 200, { ...wxRes });
|
||||
}
|
||||
|
||||
async function handleEncrypt(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const body = await readJsonBody(req) as EncryptRequestBody;
|
||||
const wechat = Array.isArray(body.wechat) ? body.wechat : [];
|
||||
|
||||
if (wechat.length === 0) {
|
||||
sendJson(res, 400, { code: 400, msg: "Missing required fields" });
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = wechat.map((item) => ({
|
||||
name: typeof item.name === "string" ? item.name : "",
|
||||
appid: typeof item.appid === "string" ? item.appid : "",
|
||||
secret: "SECRET" + encrypt(typeof item.secret === "string" ? item.secret : ""),
|
||||
}));
|
||||
|
||||
sendJson(res, 200, { code: 0, wechat: encrypted });
|
||||
}
|
||||
|
||||
function handleInfo(authkey: string, res: http.ServerResponse): void {
|
||||
sendJson(res, 200, {
|
||||
code: 0,
|
||||
name: authkey || "note2any",
|
||||
status: "active",
|
||||
created: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function requestHandler(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const pathname = url.pathname;
|
||||
|
||||
try {
|
||||
if (method === "GET" && pathname === "/") {
|
||||
sendJson(res, 200, { status: "ok", service: "note2any-wx-server" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && pathname === "/v1/wx/token") {
|
||||
await handleToken(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && pathname === "/v1/wx/encrypt") {
|
||||
await handleEncrypt(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && pathname.startsWith("/v1/wx/info/")) {
|
||||
const authkey = decodeURIComponent(pathname.slice("/v1/wx/info/".length));
|
||||
handleInfo(authkey, res);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { code: 404, msg: "Not Found" });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown server error";
|
||||
console.error(`[server] ${method} ${pathname} failed: ${message}`);
|
||||
sendJson(res, 500, { code: 500, msg: message });
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
void requestHandler(req, res);
|
||||
});
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`note2any-wx-server running on port ${config.port}`);
|
||||
});
|
||||
31
server/src/users.ts
Normal file
31
server/src/users.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as fs from "fs";
|
||||
import { config } from "./config";
|
||||
|
||||
export interface UserRecord {
|
||||
name: string;
|
||||
status: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface UsersFile {
|
||||
users: Record<string, UserRecord>;
|
||||
}
|
||||
|
||||
function readUsersFile(): UsersFile {
|
||||
const content = fs.readFileSync(config.usersFile, "utf8");
|
||||
return JSON.parse(content) as UsersFile;
|
||||
}
|
||||
|
||||
export function validateAuthKey(authkey: string): UserRecord | null {
|
||||
const users = readUsersFile().users;
|
||||
const user = users[authkey];
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user.status !== "active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
27
server/src/wechat.ts
Normal file
27
server/src/wechat.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import * as https from "https";
|
||||
|
||||
export interface WechatTokenResponse {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
export function httpsGetJson(url: string): Promise<WechatTokenResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, (res: IncomingMessage) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(data) as WechatTokenResponse);
|
||||
} catch (_error) {
|
||||
reject(new Error("Invalid JSON from WeChat API"));
|
||||
}
|
||||
});
|
||||
}).on("error", reject);
|
||||
});
|
||||
}
|
||||
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2019",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user