Upload 84 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- Dockerfile +61 -0
- LICENSE +21 -0
- apps/.DS_Store +0 -0
- apps/server/.DS_Store +0 -0
- apps/server/.env.local.example +28 -0
- apps/server/.eslintrc.js +25 -0
- apps/server/.gitignore +6 -0
- apps/server/.prettierrc.json +5 -0
- apps/server/README.md +73 -0
- apps/server/docker-bootstrap.sh +8 -0
- apps/server/nest-cli.json +8 -0
- apps/server/package.json +93 -0
- apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql +33 -0
- apps/server/prisma-sqlite/migrations/migration_lock.toml +3 -0
- apps/server/prisma-sqlite/schema.prisma +56 -0
- apps/server/prisma/migrations/20240227153512_init/migration.sql +39 -0
- apps/server/prisma/migrations/migration_lock.toml +3 -0
- apps/server/prisma/schema.prisma +56 -0
- apps/server/src/app.controller.spec.ts +22 -0
- apps/server/src/app.controller.ts +31 -0
- apps/server/src/app.module.ts +39 -0
- apps/server/src/app.service.ts +19 -0
- apps/server/src/configuration.ts +35 -0
- apps/server/src/constants.ts +14 -0
- apps/server/src/feeds/feeds.controller.spec.ts +18 -0
- apps/server/src/feeds/feeds.controller.ts +64 -0
- apps/server/src/feeds/feeds.module.ts +12 -0
- apps/server/src/feeds/feeds.service.spec.ts +18 -0
- apps/server/src/feeds/feeds.service.ts +265 -0
- apps/server/src/main.ts +49 -0
- apps/server/src/prisma/prisma.module.ts +8 -0
- apps/server/src/prisma/prisma.service.ts +9 -0
- apps/server/src/trpc/trpc.module.ts +12 -0
- apps/server/src/trpc/trpc.router.ts +421 -0
- apps/server/src/trpc/trpc.service.ts +231 -0
- apps/server/test/app.e2e-spec.ts +24 -0
- apps/server/test/jest-e2e.json +9 -0
- apps/server/tsconfig.build.json +4 -0
- apps/server/tsconfig.json +13 -0
- apps/web/.env.local.example +2 -0
- apps/web/.eslintrc.cjs +19 -0
- apps/web/.gitignore +24 -0
- apps/web/README.md +30 -0
- apps/web/index.html +17 -0
- apps/web/package.json +43 -0
- apps/web/postcss.config.js +6 -0
- apps/web/src/App.tsx +28 -0
- apps/web/src/components/GitHubIcon.tsx +26 -0
- apps/web/src/components/Nav.tsx +112 -0
- apps/web/src/components/PlusIcon.tsx +30 -0
Dockerfile
ADDED
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM node:20-alpine AS base
|
2 |
+
ENV PNPM_HOME="/pnpm"
|
3 |
+
ENV PATH="$PNPM_HOME:$PATH"
|
4 |
+
|
5 |
+
RUN npm i -g pnpm
|
6 |
+
|
7 |
+
FROM base AS build
|
8 |
+
COPY . /usr/src/app
|
9 |
+
WORKDIR /usr/src/app
|
10 |
+
|
11 |
+
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
12 |
+
|
13 |
+
RUN pnpm run -r build
|
14 |
+
|
15 |
+
RUN pnpm deploy --filter=server --prod /app
|
16 |
+
RUN pnpm deploy --filter=server --prod /app-sqlite
|
17 |
+
|
18 |
+
RUN cd /app && pnpm exec prisma generate
|
19 |
+
|
20 |
+
RUN cd /app-sqlite && \
|
21 |
+
rm -rf ./prisma && \
|
22 |
+
mv prisma-sqlite prisma && \
|
23 |
+
pnpm exec prisma generate
|
24 |
+
|
25 |
+
FROM base AS app-sqlite
|
26 |
+
COPY --from=build /app-sqlite /app
|
27 |
+
|
28 |
+
WORKDIR /app
|
29 |
+
|
30 |
+
EXPOSE 4000
|
31 |
+
|
32 |
+
ENV NODE_ENV=production
|
33 |
+
ENV HOST="0.0.0.0"
|
34 |
+
ENV SERVER_ORIGIN_URL=""
|
35 |
+
ENV MAX_REQUEST_PER_MINUTE=60
|
36 |
+
ENV AUTH_CODE=""
|
37 |
+
ENV DATABASE_URL="file:../data/wewe-rss.db"
|
38 |
+
ENV DATABASE_TYPE="sqlite"
|
39 |
+
|
40 |
+
RUN chmod +x ./docker-bootstrap.sh
|
41 |
+
|
42 |
+
CMD ["./docker-bootstrap.sh"]
|
43 |
+
|
44 |
+
|
45 |
+
FROM base AS app
|
46 |
+
COPY --from=build /app /app
|
47 |
+
|
48 |
+
WORKDIR /app
|
49 |
+
|
50 |
+
EXPOSE 4000
|
51 |
+
|
52 |
+
ENV NODE_ENV=production
|
53 |
+
ENV HOST="0.0.0.0"
|
54 |
+
ENV SERVER_ORIGIN_URL=""
|
55 |
+
ENV MAX_REQUEST_PER_MINUTE=60
|
56 |
+
ENV AUTH_CODE=""
|
57 |
+
ENV DATABASE_URL=""
|
58 |
+
|
59 |
+
RUN chmod +x ./docker-bootstrap.sh
|
60 |
+
|
61 |
+
CMD ["./docker-bootstrap.sh"]
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2024 cooderl
|
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.
|
apps/.DS_Store
ADDED
Binary file (6.15 kB). View file
|
|
apps/server/.DS_Store
ADDED
Binary file (6.15 kB). View file
|
|
apps/server/.env.local.example
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
HOST=0.0.0.0
|
2 |
+
PORT=4000
|
3 |
+
|
4 |
+
# Prisma
|
5 |
+
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
6 |
+
DATABASE_URL="mysql://root:[email protected]:3306/wewe-rss"
|
7 |
+
|
8 |
+
# 使用Sqlite
|
9 |
+
# DATABASE_URL="file:../data/wewe-rss.db"
|
10 |
+
# DATABASE_TYPE="sqlite"
|
11 |
+
|
12 |
+
# 访问授权码
|
13 |
+
AUTH_CODE=123567
|
14 |
+
|
15 |
+
# 每分钟最大请求次数
|
16 |
+
MAX_REQUEST_PER_MINUTE=60
|
17 |
+
|
18 |
+
# 自动提取全文内容
|
19 |
+
FEED_MODE="fulltext"
|
20 |
+
|
21 |
+
# nginx 转发后的服务端地址
|
22 |
+
SERVER_ORIGIN_URL=http://localhost:4000
|
23 |
+
|
24 |
+
# 定时更新订阅源Cron表达式
|
25 |
+
CRON_EXPRESSION="35 5,17 * * *"
|
26 |
+
|
27 |
+
# 读书转发服务,不需要修改
|
28 |
+
PLATFORM_URL="https://weread.111965.xyz"
|
apps/server/.eslintrc.js
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
module.exports = {
|
2 |
+
parser: '@typescript-eslint/parser',
|
3 |
+
parserOptions: {
|
4 |
+
project: 'tsconfig.json',
|
5 |
+
tsconfigRootDir: __dirname,
|
6 |
+
sourceType: 'module',
|
7 |
+
},
|
8 |
+
plugins: ['@typescript-eslint/eslint-plugin'],
|
9 |
+
extends: [
|
10 |
+
'plugin:@typescript-eslint/recommended',
|
11 |
+
'plugin:prettier/recommended',
|
12 |
+
],
|
13 |
+
root: true,
|
14 |
+
env: {
|
15 |
+
node: true,
|
16 |
+
jest: true,
|
17 |
+
},
|
18 |
+
ignorePatterns: ['.eslintrc.js'],
|
19 |
+
rules: {
|
20 |
+
'@typescript-eslint/interface-name-prefix': 'off',
|
21 |
+
'@typescript-eslint/explicit-function-return-type': 'off',
|
22 |
+
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
23 |
+
'@typescript-eslint/no-explicit-any': 'off',
|
24 |
+
},
|
25 |
+
};
|
apps/server/.gitignore
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
node_modules
|
2 |
+
# Keep environment variables out of version control
|
3 |
+
.env
|
4 |
+
|
5 |
+
client
|
6 |
+
data
|
apps/server/.prettierrc.json
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"tabWidth": 2,
|
3 |
+
"singleQuote": true,
|
4 |
+
"trailingComma": "all"
|
5 |
+
}
|
apps/server/README.md
ADDED
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<p align="center">
|
2 |
+
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
|
3 |
+
</p>
|
4 |
+
|
5 |
+
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
6 |
+
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
7 |
+
|
8 |
+
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
9 |
+
<p align="center">
|
10 |
+
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
11 |
+
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
12 |
+
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
13 |
+
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
14 |
+
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
15 |
+
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
16 |
+
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
17 |
+
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
18 |
+
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
|
19 |
+
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
20 |
+
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
|
21 |
+
</p>
|
22 |
+
<!--[](https://opencollective.com/nest#backer)
|
23 |
+
[](https://opencollective.com/nest#sponsor)-->
|
24 |
+
|
25 |
+
## Description
|
26 |
+
|
27 |
+
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
28 |
+
|
29 |
+
## Installation
|
30 |
+
|
31 |
+
```bash
|
32 |
+
$ pnpm install
|
33 |
+
```
|
34 |
+
|
35 |
+
## Running the app
|
36 |
+
|
37 |
+
```bash
|
38 |
+
# development
|
39 |
+
$ pnpm run start
|
40 |
+
|
41 |
+
# watch mode
|
42 |
+
$ pnpm run start:dev
|
43 |
+
|
44 |
+
# production mode
|
45 |
+
$ pnpm run start:prod
|
46 |
+
```
|
47 |
+
|
48 |
+
## Test
|
49 |
+
|
50 |
+
```bash
|
51 |
+
# unit tests
|
52 |
+
$ pnpm run test
|
53 |
+
|
54 |
+
# e2e tests
|
55 |
+
$ pnpm run test:e2e
|
56 |
+
|
57 |
+
# test coverage
|
58 |
+
$ pnpm run test:cov
|
59 |
+
```
|
60 |
+
|
61 |
+
## Support
|
62 |
+
|
63 |
+
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
64 |
+
|
65 |
+
## Stay in touch
|
66 |
+
|
67 |
+
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
|
68 |
+
- Website - [https://nestjs.com](https://nestjs.com/)
|
69 |
+
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
70 |
+
|
71 |
+
## License
|
72 |
+
|
73 |
+
Nest is [MIT licensed](LICENSE).
|
apps/server/docker-bootstrap.sh
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
#!/bin/sh
|
3 |
+
# ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses
|
4 |
+
# Need to explicit pass DATABASE_URL here, otherwise migration doesn't work
|
5 |
+
# Run migrations
|
6 |
+
DATABASE_URL=${DATABASE_URL} npx prisma migrate deploy
|
7 |
+
# start app
|
8 |
+
DATABASE_URL=${DATABASE_URL} node dist/main
|
apps/server/nest-cli.json
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"$schema": "https://json.schemastore.org/nest-cli",
|
3 |
+
"collection": "@nestjs/schematics",
|
4 |
+
"sourceRoot": "src",
|
5 |
+
"compilerOptions": {
|
6 |
+
"deleteOutDir": true
|
7 |
+
}
|
8 |
+
}
|
apps/server/package.json
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "server",
|
3 |
+
"version": "1.7.0",
|
4 |
+
"description": "",
|
5 |
+
"author": "",
|
6 |
+
"private": true,
|
7 |
+
"license": "UNLICENSED",
|
8 |
+
"scripts": {
|
9 |
+
"build": "nest build",
|
10 |
+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
11 |
+
"start": "nest start",
|
12 |
+
"dev": "nest start --watch",
|
13 |
+
"start:debug": "nest start --debug --watch",
|
14 |
+
"start:prod": "node dist/main",
|
15 |
+
"start:migrate:prod": "prisma migrate deploy && npm run start:prod",
|
16 |
+
"postinstall": "npx prisma generate",
|
17 |
+
"migrate": "pnpm prisma migrate dev",
|
18 |
+
"studio": "pnpm prisma studio",
|
19 |
+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
20 |
+
"test": "jest",
|
21 |
+
"test:watch": "jest --watch",
|
22 |
+
"test:cov": "jest --coverage",
|
23 |
+
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
24 |
+
"test:e2e": "jest --config ./test/jest-e2e.json"
|
25 |
+
},
|
26 |
+
"dependencies": {
|
27 |
+
"@cjs-exporter/p-map": "^5.5.0",
|
28 |
+
"@nestjs/common": "^10.3.3",
|
29 |
+
"@nestjs/config": "^3.2.0",
|
30 |
+
"@nestjs/core": "^10.3.3",
|
31 |
+
"@nestjs/platform-express": "^10.3.3",
|
32 |
+
"@nestjs/schedule": "^4.0.1",
|
33 |
+
"@nestjs/throttler": "^5.1.2",
|
34 |
+
"@prisma/client": "5.10.1",
|
35 |
+
"@trpc/server": "^10.45.1",
|
36 |
+
"axios": "^1.6.7",
|
37 |
+
"cheerio": "1.0.0-rc.12",
|
38 |
+
"class-transformer": "^0.5.1",
|
39 |
+
"class-validator": "^0.14.1",
|
40 |
+
"dayjs": "^1.11.10",
|
41 |
+
"express": "^4.18.2",
|
42 |
+
"feed": "^4.2.2",
|
43 |
+
"got": "11.8.6",
|
44 |
+
"hbs": "^4.2.0",
|
45 |
+
"html-minifier": "^4.0.0",
|
46 |
+
"node-cache": "^5.1.2",
|
47 |
+
"prisma": "^5.10.2",
|
48 |
+
"reflect-metadata": "^0.2.1",
|
49 |
+
"rxjs": "^7.8.1",
|
50 |
+
"zod": "^3.22.4"
|
51 |
+
},
|
52 |
+
"devDependencies": {
|
53 |
+
"@nestjs/cli": "^10.3.2",
|
54 |
+
"@nestjs/schematics": "^10.1.1",
|
55 |
+
"@nestjs/testing": "^10.3.3",
|
56 |
+
"@types/express": "^4.17.21",
|
57 |
+
"@types/html-minifier": "^4.0.5",
|
58 |
+
"@types/jest": "^29.5.12",
|
59 |
+
"@types/node": "^20.11.19",
|
60 |
+
"@types/supertest": "^6.0.2",
|
61 |
+
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
62 |
+
"@typescript-eslint/parser": "^7.0.2",
|
63 |
+
"eslint": "^8.56.0",
|
64 |
+
"eslint-config-prettier": "^9.1.0",
|
65 |
+
"eslint-plugin-prettier": "^5.1.3",
|
66 |
+
"jest": "^29.7.0",
|
67 |
+
"prettier": "^3.2.5",
|
68 |
+
"source-map-support": "^0.5.21",
|
69 |
+
"supertest": "^6.3.4",
|
70 |
+
"ts-jest": "^29.1.2",
|
71 |
+
"ts-loader": "^9.5.1",
|
72 |
+
"ts-node": "^10.9.2",
|
73 |
+
"tsconfig-paths": "^4.2.0",
|
74 |
+
"typescript": "^5.3.3"
|
75 |
+
},
|
76 |
+
"jest": {
|
77 |
+
"moduleFileExtensions": [
|
78 |
+
"js",
|
79 |
+
"json",
|
80 |
+
"ts"
|
81 |
+
],
|
82 |
+
"rootDir": "src",
|
83 |
+
"testRegex": ".*\\.spec\\.ts$",
|
84 |
+
"transform": {
|
85 |
+
"^.+\\.(t|j)s$": "ts-jest"
|
86 |
+
},
|
87 |
+
"collectCoverageFrom": [
|
88 |
+
"**/*.(t|j)s"
|
89 |
+
],
|
90 |
+
"coverageDirectory": "../coverage",
|
91 |
+
"testEnvironment": "node"
|
92 |
+
}
|
93 |
+
}
|
apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
-- CreateTable
|
2 |
+
CREATE TABLE "accounts" (
|
3 |
+
"id" TEXT NOT NULL PRIMARY KEY,
|
4 |
+
"token" TEXT NOT NULL,
|
5 |
+
"name" TEXT NOT NULL,
|
6 |
+
"status" INTEGER NOT NULL DEFAULT 1,
|
7 |
+
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
8 |
+
"updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
|
9 |
+
);
|
10 |
+
|
11 |
+
-- CreateTable
|
12 |
+
CREATE TABLE "feeds" (
|
13 |
+
"id" TEXT NOT NULL PRIMARY KEY,
|
14 |
+
"mp_name" TEXT NOT NULL,
|
15 |
+
"mp_cover" TEXT NOT NULL,
|
16 |
+
"mp_intro" TEXT NOT NULL,
|
17 |
+
"status" INTEGER NOT NULL DEFAULT 1,
|
18 |
+
"sync_time" INTEGER NOT NULL DEFAULT 0,
|
19 |
+
"update_time" INTEGER NOT NULL,
|
20 |
+
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
21 |
+
"updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
|
22 |
+
);
|
23 |
+
|
24 |
+
-- CreateTable
|
25 |
+
CREATE TABLE "articles" (
|
26 |
+
"id" TEXT NOT NULL PRIMARY KEY,
|
27 |
+
"mp_id" TEXT NOT NULL,
|
28 |
+
"title" TEXT NOT NULL,
|
29 |
+
"pic_url" TEXT NOT NULL,
|
30 |
+
"publish_time" INTEGER NOT NULL,
|
31 |
+
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
32 |
+
"updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
|
33 |
+
);
|
apps/server/prisma-sqlite/migrations/migration_lock.toml
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
# Please do not edit this file manually
|
2 |
+
# It should be added in your version-control system (i.e. Git)
|
3 |
+
provider = "sqlite"
|
apps/server/prisma-sqlite/schema.prisma
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
datasource db {
|
2 |
+
provider = "sqlite"
|
3 |
+
url = env("DATABASE_URL")
|
4 |
+
}
|
5 |
+
|
6 |
+
generator client {
|
7 |
+
provider = "prisma-client-js"
|
8 |
+
binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
|
9 |
+
}
|
10 |
+
|
11 |
+
// 读书账号
|
12 |
+
model Account {
|
13 |
+
id String @id
|
14 |
+
token String @map("token")
|
15 |
+
name String @map("name")
|
16 |
+
// 状态 0:失效 1:启用 2:禁用
|
17 |
+
status Int @default(1) @map("status")
|
18 |
+
createdAt DateTime @default(now()) @map("created_at")
|
19 |
+
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
|
20 |
+
|
21 |
+
@@map("accounts")
|
22 |
+
}
|
23 |
+
|
24 |
+
// 订阅源
|
25 |
+
model Feed {
|
26 |
+
id String @id
|
27 |
+
mpName String @map("mp_name")
|
28 |
+
mpCover String @map("mp_cover")
|
29 |
+
mpIntro String @map("mp_intro")
|
30 |
+
// 状态 0:失效 1:启用 2:禁用
|
31 |
+
status Int @default(1) @map("status")
|
32 |
+
|
33 |
+
// article最后同步时间
|
34 |
+
syncTime Int @default(0) @map("sync_time")
|
35 |
+
|
36 |
+
// 信息更新时间
|
37 |
+
updateTime Int @map("update_time")
|
38 |
+
|
39 |
+
createdAt DateTime @default(now()) @map("created_at")
|
40 |
+
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
|
41 |
+
|
42 |
+
@@map("feeds")
|
43 |
+
}
|
44 |
+
|
45 |
+
model Article {
|
46 |
+
id String @id
|
47 |
+
mpId String @map("mp_id")
|
48 |
+
title String @map("title")
|
49 |
+
picUrl String @map("pic_url")
|
50 |
+
publishTime Int @map("publish_time")
|
51 |
+
|
52 |
+
createdAt DateTime @default(now()) @map("created_at")
|
53 |
+
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
|
54 |
+
|
55 |
+
@@map("articles")
|
56 |
+
}
|
apps/server/prisma/migrations/20240227153512_init/migration.sql
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
-- CreateTable
|
2 |
+
CREATE TABLE `accounts` (
|
3 |
+
`id` VARCHAR(255) NOT NULL,
|
4 |
+
`token` VARCHAR(2048) NOT NULL,
|
5 |
+
`name` VARCHAR(1024) NOT NULL,
|
6 |
+
`status` INTEGER NOT NULL DEFAULT 1,
|
7 |
+
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
8 |
+
`updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
9 |
+
|
10 |
+
PRIMARY KEY (`id`)
|
11 |
+
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
12 |
+
|
13 |
+
-- CreateTable
|
14 |
+
CREATE TABLE `feeds` (
|
15 |
+
`id` VARCHAR(255) NOT NULL,
|
16 |
+
`mp_name` VARCHAR(512) NOT NULL,
|
17 |
+
`mp_cover` VARCHAR(1024) NOT NULL,
|
18 |
+
`mp_intro` TEXT NOT NULL,
|
19 |
+
`status` INTEGER NOT NULL DEFAULT 1,
|
20 |
+
`sync_time` INTEGER NOT NULL DEFAULT 0,
|
21 |
+
`update_time` INTEGER NOT NULL,
|
22 |
+
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
23 |
+
`updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
24 |
+
|
25 |
+
PRIMARY KEY (`id`)
|
26 |
+
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
27 |
+
|
28 |
+
-- CreateTable
|
29 |
+
CREATE TABLE `articles` (
|
30 |
+
`id` VARCHAR(255) NOT NULL,
|
31 |
+
`mp_id` VARCHAR(255) NOT NULL,
|
32 |
+
`title` VARCHAR(255) NOT NULL,
|
33 |
+
`pic_url` VARCHAR(255) NOT NULL,
|
34 |
+
`publish_time` INTEGER NOT NULL,
|
35 |
+
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
36 |
+
`updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
|
37 |
+
|
38 |
+
PRIMARY KEY (`id`)
|
39 |
+
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
apps/server/prisma/migrations/migration_lock.toml
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
# Please do not edit this file manually
|
2 |
+
# It should be added in your version-control system (i.e. Git)
|
3 |
+
provider = "mysql"
|
apps/server/prisma/schema.prisma
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
datasource db {
|
2 |
+
provider = "mysql"
|
3 |
+
url = env("DATABASE_URL")
|
4 |
+
}
|
5 |
+
|
6 |
+
generator client {
|
7 |
+
provider = "prisma-client-js"
|
8 |
+
binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
|
9 |
+
}
|
10 |
+
|
11 |
+
// 读书账号
|
12 |
+
model Account {
|
13 |
+
id String @id @db.VarChar(255)
|
14 |
+
token String @map("token") @db.VarChar(2048)
|
15 |
+
name String @map("name") @db.VarChar(1024)
|
16 |
+
// 状态 0:失效 1:启用 2:禁用
|
17 |
+
status Int @default(1) @map("status") @db.Int()
|
18 |
+
createdAt DateTime @default(now()) @map("created_at")
|
19 |
+
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
|
20 |
+
|
21 |
+
@@map("accounts")
|
22 |
+
}
|
23 |
+
|
24 |
+
// 订阅源
|
25 |
+
model Feed {
|
26 |
+
id String @id @db.VarChar(255)
|
27 |
+
mpName String @map("mp_name") @db.VarChar(512)
|
28 |
+
mpCover String @map("mp_cover") @db.VarChar(1024)
|
29 |
+
mpIntro String @map("mp_intro") @db.Text()
|
30 |
+
// 状态 0:失效 1:启用 2:禁用
|
31 |
+
status Int @default(1) @map("status") @db.Int()
|
32 |
+
|
33 |
+
// article最后同步时间
|
34 |
+
syncTime Int @default(0) @map("sync_time")
|
35 |
+
|
36 |
+
// 信息更新时间
|
37 |
+
updateTime Int @map("update_time")
|
38 |
+
|
39 |
+
createdAt DateTime @default(now()) @map("created_at")
|
40 |
+
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
|
41 |
+
|
42 |
+
@@map("feeds")
|
43 |
+
}
|
44 |
+
|
45 |
+
model Article {
|
46 |
+
id String @id @db.VarChar(255)
|
47 |
+
mpId String @map("mp_id") @db.VarChar(255)
|
48 |
+
title String @map("title") @db.VarChar(255)
|
49 |
+
picUrl String @map("pic_url") @db.VarChar(255)
|
50 |
+
publishTime Int @map("publish_time")
|
51 |
+
|
52 |
+
createdAt DateTime @default(now()) @map("created_at")
|
53 |
+
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
|
54 |
+
|
55 |
+
@@map("articles")
|
56 |
+
}
|
apps/server/src/app.controller.spec.ts
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Test, TestingModule } from '@nestjs/testing';
|
2 |
+
import { AppController } from './app.controller';
|
3 |
+
import { AppService } from './app.service';
|
4 |
+
|
5 |
+
describe('AppController', () => {
|
6 |
+
let appController: AppController;
|
7 |
+
|
8 |
+
beforeEach(async () => {
|
9 |
+
const app: TestingModule = await Test.createTestingModule({
|
10 |
+
controllers: [AppController],
|
11 |
+
providers: [AppService],
|
12 |
+
}).compile();
|
13 |
+
|
14 |
+
appController = app.get<AppController>(AppController);
|
15 |
+
});
|
16 |
+
|
17 |
+
describe('root', () => {
|
18 |
+
it('should return "Hello World!"', () => {
|
19 |
+
expect(appController.getHello()).toBe('Hello World!');
|
20 |
+
});
|
21 |
+
});
|
22 |
+
});
|
apps/server/src/app.controller.ts
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Controller, Get, Redirect, Render } from '@nestjs/common';
|
2 |
+
import { AppService } from './app.service';
|
3 |
+
|
4 |
+
@Controller()
|
5 |
+
export class AppController {
|
6 |
+
constructor(private readonly appService: AppService) {}
|
7 |
+
|
8 |
+
@Get()
|
9 |
+
getHello(): string {
|
10 |
+
return this.appService.getHello();
|
11 |
+
}
|
12 |
+
|
13 |
+
@Get('/robots.txt')
|
14 |
+
forRobot(): string {
|
15 |
+
return 'User-agent: *\nDisallow: /';
|
16 |
+
}
|
17 |
+
|
18 |
+
@Get('favicon.ico')
|
19 |
+
@Redirect('https://r2-assets.111965.xyz/wewe-rss.png', 302)
|
20 |
+
getFavicon() {}
|
21 |
+
|
22 |
+
@Get('/dash*')
|
23 |
+
@Render('index.hbs')
|
24 |
+
dashRender() {
|
25 |
+
const { originUrl: weweRssServerOriginUrl } =
|
26 |
+
this.appService.getFeedConfig();
|
27 |
+
return {
|
28 |
+
weweRssServerOriginUrl,
|
29 |
+
};
|
30 |
+
}
|
31 |
+
}
|
apps/server/src/app.module.ts
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Module } from '@nestjs/common';
|
2 |
+
import { AppController } from './app.controller';
|
3 |
+
import { AppService } from './app.service';
|
4 |
+
import { TrpcModule } from '@server/trpc/trpc.module';
|
5 |
+
import { ConfigModule, ConfigService } from '@nestjs/config';
|
6 |
+
import configuration, { ConfigurationType } from './configuration';
|
7 |
+
import { ThrottlerModule } from '@nestjs/throttler';
|
8 |
+
import { ScheduleModule } from '@nestjs/schedule';
|
9 |
+
import { FeedsModule } from './feeds/feeds.module';
|
10 |
+
|
11 |
+
@Module({
|
12 |
+
imports: [
|
13 |
+
TrpcModule,
|
14 |
+
FeedsModule,
|
15 |
+
ScheduleModule.forRoot(),
|
16 |
+
ConfigModule.forRoot({
|
17 |
+
isGlobal: true,
|
18 |
+
envFilePath: ['.env.local', '.env'],
|
19 |
+
load: [configuration],
|
20 |
+
}),
|
21 |
+
ThrottlerModule.forRootAsync({
|
22 |
+
imports: [ConfigModule],
|
23 |
+
inject: [ConfigService],
|
24 |
+
useFactory(config: ConfigService) {
|
25 |
+
const throttler =
|
26 |
+
config.get<ConfigurationType['throttler']>('throttler');
|
27 |
+
return [
|
28 |
+
{
|
29 |
+
ttl: 60,
|
30 |
+
limit: throttler?.maxRequestPerMinute || 60,
|
31 |
+
},
|
32 |
+
];
|
33 |
+
},
|
34 |
+
}),
|
35 |
+
],
|
36 |
+
controllers: [AppController],
|
37 |
+
providers: [AppService],
|
38 |
+
})
|
39 |
+
export class AppModule {}
|
apps/server/src/app.service.ts
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Injectable } from '@nestjs/common';
|
2 |
+
import { ConfigService } from '@nestjs/config';
|
3 |
+
import { ConfigurationType } from './configuration';
|
4 |
+
|
5 |
+
@Injectable()
|
6 |
+
export class AppService {
|
7 |
+
constructor(private readonly configService: ConfigService) {}
|
8 |
+
getHello(): string {
|
9 |
+
return `
|
10 |
+
<div style="display:flex;justify-content: center;height: 100%;align-items: center;font-size: 30px;">
|
11 |
+
<div>>> <a href="/dash">WeWe RSS</a> <<</div>
|
12 |
+
</div>
|
13 |
+
`;
|
14 |
+
}
|
15 |
+
|
16 |
+
getFeedConfig() {
|
17 |
+
return this.configService.get<ConfigurationType['feed']>('feed')!;
|
18 |
+
}
|
19 |
+
}
|
apps/server/src/configuration.ts
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const configuration = () => {
|
2 |
+
const isProd = process.env.NODE_ENV === 'production';
|
3 |
+
const port = process.env.PORT || 4000;
|
4 |
+
const host = process.env.HOST || '0.0.0.0';
|
5 |
+
|
6 |
+
const maxRequestPerMinute = parseInt(
|
7 |
+
`${process.env.MAX_REQUEST_PER_MINUTE}|| 60`,
|
8 |
+
);
|
9 |
+
|
10 |
+
const authCode = process.env.AUTH_CODE;
|
11 |
+
const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz';
|
12 |
+
const originUrl = process.env.SERVER_ORIGIN_URL || '';
|
13 |
+
|
14 |
+
const feedMode = process.env.FEED_MODE as 'fulltext' | '';
|
15 |
+
|
16 |
+
const databaseType = process.env.DATABASE_TYPE || 'mysql';
|
17 |
+
|
18 |
+
return {
|
19 |
+
server: { isProd, port, host },
|
20 |
+
throttler: { maxRequestPerMinute },
|
21 |
+
auth: { code: authCode },
|
22 |
+
platform: { url: platformUrl },
|
23 |
+
feed: {
|
24 |
+
originUrl,
|
25 |
+
mode: feedMode,
|
26 |
+
},
|
27 |
+
database: {
|
28 |
+
type: databaseType,
|
29 |
+
},
|
30 |
+
};
|
31 |
+
};
|
32 |
+
|
33 |
+
export default configuration;
|
34 |
+
|
35 |
+
export type ConfigurationType = ReturnType<typeof configuration>;
|
apps/server/src/constants.ts
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export const statusMap = {
|
2 |
+
// 0:失效 1:启用 2:禁用
|
3 |
+
INVALID: 0,
|
4 |
+
ENABLE: 1,
|
5 |
+
DISABLE: 2,
|
6 |
+
};
|
7 |
+
|
8 |
+
export const feedTypes = ['rss', 'atom', 'json'] as const;
|
9 |
+
|
10 |
+
export const feedMimeTypeMap = {
|
11 |
+
rss: 'application/rss+xml; charset=utf-8',
|
12 |
+
atom: 'application/atom+xml; charset=utf-8',
|
13 |
+
json: 'application/feed+json; charset=utf-8',
|
14 |
+
} as const;
|
apps/server/src/feeds/feeds.controller.spec.ts
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Test, TestingModule } from '@nestjs/testing';
|
2 |
+
import { FeedsController } from './feeds.controller';
|
3 |
+
|
4 |
+
describe('FeedsController', () => {
|
5 |
+
let controller: FeedsController;
|
6 |
+
|
7 |
+
beforeEach(async () => {
|
8 |
+
const module: TestingModule = await Test.createTestingModule({
|
9 |
+
controllers: [FeedsController],
|
10 |
+
}).compile();
|
11 |
+
|
12 |
+
controller = module.get<FeedsController>(FeedsController);
|
13 |
+
});
|
14 |
+
|
15 |
+
it('should be defined', () => {
|
16 |
+
expect(controller).toBeDefined();
|
17 |
+
});
|
18 |
+
});
|
apps/server/src/feeds/feeds.controller.ts
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Controller,
|
3 |
+
DefaultValuePipe,
|
4 |
+
Get,
|
5 |
+
Logger,
|
6 |
+
Param,
|
7 |
+
ParseIntPipe,
|
8 |
+
Query,
|
9 |
+
Request,
|
10 |
+
Response,
|
11 |
+
} from '@nestjs/common';
|
12 |
+
import { FeedsService } from './feeds.service';
|
13 |
+
import { Response as Res, Request as Req } from 'express';
|
14 |
+
|
15 |
+
@Controller('feeds')
|
16 |
+
export class FeedsController {
|
17 |
+
private readonly logger = new Logger(this.constructor.name);
|
18 |
+
|
19 |
+
constructor(private readonly feedsService: FeedsService) {}
|
20 |
+
|
21 |
+
@Get('/')
|
22 |
+
async getFeedList() {
|
23 |
+
return this.feedsService.getFeedList();
|
24 |
+
}
|
25 |
+
|
26 |
+
@Get('/all.(json|rss|atom)')
|
27 |
+
async getFeeds(
|
28 |
+
@Request() req: Req,
|
29 |
+
@Response() res: Res,
|
30 |
+
@Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30,
|
31 |
+
@Query('mode') mode: string,
|
32 |
+
) {
|
33 |
+
const path = req.path;
|
34 |
+
const type = path.split('.').pop() || '';
|
35 |
+
const { content, mimeType } = await this.feedsService.handleGenerateFeed({
|
36 |
+
type,
|
37 |
+
limit,
|
38 |
+
mode,
|
39 |
+
});
|
40 |
+
|
41 |
+
res.setHeader('Content-Type', mimeType);
|
42 |
+
res.send(content);
|
43 |
+
}
|
44 |
+
|
45 |
+
@Get('/:feed')
|
46 |
+
async getFeed(
|
47 |
+
@Response() res: Res,
|
48 |
+
@Param('feed') feed: string,
|
49 |
+
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10,
|
50 |
+
@Query('mode') mode: string,
|
51 |
+
) {
|
52 |
+
const [id, type] = feed.split('.');
|
53 |
+
this.logger.log('getFeed: ', id);
|
54 |
+
const { content, mimeType } = await this.feedsService.handleGenerateFeed({
|
55 |
+
id,
|
56 |
+
type,
|
57 |
+
limit,
|
58 |
+
mode,
|
59 |
+
});
|
60 |
+
|
61 |
+
res.setHeader('Content-Type', mimeType);
|
62 |
+
res.send(content);
|
63 |
+
}
|
64 |
+
}
|
apps/server/src/feeds/feeds.module.ts
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Module } from '@nestjs/common';
|
2 |
+
import { FeedsController } from './feeds.controller';
|
3 |
+
import { FeedsService } from './feeds.service';
|
4 |
+
import { PrismaModule } from '@server/prisma/prisma.module';
|
5 |
+
import { TrpcModule } from '@server/trpc/trpc.module';
|
6 |
+
|
7 |
+
@Module({
|
8 |
+
imports: [PrismaModule, TrpcModule],
|
9 |
+
controllers: [FeedsController],
|
10 |
+
providers: [FeedsService],
|
11 |
+
})
|
12 |
+
export class FeedsModule {}
|
apps/server/src/feeds/feeds.service.spec.ts
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Test, TestingModule } from '@nestjs/testing';
|
2 |
+
import { FeedsService } from './feeds.service';
|
3 |
+
|
4 |
+
describe('FeedsService', () => {
|
5 |
+
let service: FeedsService;
|
6 |
+
|
7 |
+
beforeEach(async () => {
|
8 |
+
const module: TestingModule = await Test.createTestingModule({
|
9 |
+
providers: [FeedsService],
|
10 |
+
}).compile();
|
11 |
+
|
12 |
+
service = module.get<FeedsService>(FeedsService);
|
13 |
+
});
|
14 |
+
|
15 |
+
it('should be defined', () => {
|
16 |
+
expect(service).toBeDefined();
|
17 |
+
});
|
18 |
+
});
|
apps/server/src/feeds/feeds.service.ts
ADDED
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
|
2 |
+
import { PrismaService } from '@server/prisma/prisma.service';
|
3 |
+
import { Cron } from '@nestjs/schedule';
|
4 |
+
import { TrpcService } from '@server/trpc/trpc.service';
|
5 |
+
import { feedMimeTypeMap, feedTypes } from '@server/constants';
|
6 |
+
import { ConfigService } from '@nestjs/config';
|
7 |
+
import { Article, Feed as FeedInfo } from '@prisma/client';
|
8 |
+
import { ConfigurationType } from '@server/configuration';
|
9 |
+
import { Feed } from 'feed';
|
10 |
+
import got, { Got } from 'got';
|
11 |
+
import { load } from 'cheerio';
|
12 |
+
import { minify } from 'html-minifier';
|
13 |
+
import NodeCache from 'node-cache';
|
14 |
+
import pMap from '@cjs-exporter/p-map';
|
15 |
+
|
16 |
+
console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION);
|
17 |
+
|
18 |
+
const mpCache = new NodeCache({
|
19 |
+
maxKeys: 1000,
|
20 |
+
});
|
21 |
+
|
22 |
+
@Injectable()
|
23 |
+
export class FeedsService {
|
24 |
+
private readonly logger = new Logger(this.constructor.name);
|
25 |
+
|
26 |
+
private request: Got;
|
27 |
+
constructor(
|
28 |
+
private readonly prismaService: PrismaService,
|
29 |
+
private readonly trpcService: TrpcService,
|
30 |
+
private readonly configService: ConfigService,
|
31 |
+
) {
|
32 |
+
this.request = got.extend({
|
33 |
+
retry: {
|
34 |
+
limit: 3,
|
35 |
+
methods: ['GET'],
|
36 |
+
},
|
37 |
+
headers: {
|
38 |
+
accept:
|
39 |
+
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
40 |
+
'accept-encoding': 'gzip, deflate, br',
|
41 |
+
'accept-language': 'en-US,en;q=0.9',
|
42 |
+
'cache-control': 'max-age=0',
|
43 |
+
'sec-ch-ua':
|
44 |
+
'" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"',
|
45 |
+
'sec-ch-ua-mobile': '?0',
|
46 |
+
'sec-ch-ua-platform': '"macOS"',
|
47 |
+
'sec-fetch-dest': 'document',
|
48 |
+
'sec-fetch-mode': 'navigate',
|
49 |
+
'sec-fetch-site': 'none',
|
50 |
+
'sec-fetch-user': '?1',
|
51 |
+
'upgrade-insecure-requests': '1',
|
52 |
+
'user-agent':
|
53 |
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36',
|
54 |
+
},
|
55 |
+
});
|
56 |
+
}
|
57 |
+
|
58 |
+
@Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', {
|
59 |
+
name: 'updateFeeds',
|
60 |
+
timeZone: 'Asia/Shanghai',
|
61 |
+
})
|
62 |
+
async handleUpdateFeedsCron() {
|
63 |
+
this.logger.debug('Called handleUpdateFeedsCron');
|
64 |
+
|
65 |
+
const feeds = await this.prismaService.feed.findMany({
|
66 |
+
where: { status: 1 },
|
67 |
+
});
|
68 |
+
this.logger.debug('feeds length:' + feeds.length);
|
69 |
+
|
70 |
+
for (const feed of feeds) {
|
71 |
+
this.logger.debug('feed', feed.id);
|
72 |
+
await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);
|
73 |
+
// wait 30s for next feed
|
74 |
+
await new Promise((resolve) => setTimeout(resolve, 90 * 1e3));
|
75 |
+
}
|
76 |
+
}
|
77 |
+
|
78 |
+
async cleanHtml(source: string) {
|
79 |
+
const $ = load(source, { decodeEntities: false });
|
80 |
+
|
81 |
+
const dirtyHtml = $.html($('.rich_media_content'));
|
82 |
+
|
83 |
+
const html = dirtyHtml
|
84 |
+
.replace(/data-src=/g, 'src=')
|
85 |
+
.replace(/visibility: hidden;/g, '');
|
86 |
+
|
87 |
+
const content =
|
88 |
+
'<style> .rich_media_content {overflow: hidden;color: #222;font-size: 17px;word-wrap: break-word;-webkit-hyphens: auto;-ms-hyphens: auto;hyphens: auto;text-align: justify;position: relative;z-index: 0;}.rich_media_content {font-size: 18px;}</style>' +
|
89 |
+
html;
|
90 |
+
|
91 |
+
const result = minify(content, {
|
92 |
+
removeAttributeQuotes: true,
|
93 |
+
collapseWhitespace: true,
|
94 |
+
});
|
95 |
+
|
96 |
+
return result;
|
97 |
+
}
|
98 |
+
|
99 |
+
async getHtmlByUrl(url: string) {
|
100 |
+
const html = await this.request(url, { responseType: 'text' }).text();
|
101 |
+
const result = await this.cleanHtml(html);
|
102 |
+
|
103 |
+
return result;
|
104 |
+
}
|
105 |
+
|
106 |
+
async tryGetContent(id: string) {
|
107 |
+
let content = mpCache.get(id) as string;
|
108 |
+
if (content) {
|
109 |
+
return content;
|
110 |
+
}
|
111 |
+
const url = `https://mp.weixin.qq.com/s/${id}`;
|
112 |
+
content = await this.getHtmlByUrl(url).catch((e) => {
|
113 |
+
this.logger.error('getHtmlByUrl error:', e);
|
114 |
+
return '';
|
115 |
+
});
|
116 |
+
mpCache.set(id, content);
|
117 |
+
return content;
|
118 |
+
}
|
119 |
+
|
120 |
+
async renderFeed({
|
121 |
+
type,
|
122 |
+
feedInfo,
|
123 |
+
articles,
|
124 |
+
mode,
|
125 |
+
}: {
|
126 |
+
type: string;
|
127 |
+
feedInfo: FeedInfo;
|
128 |
+
articles: Article[];
|
129 |
+
mode?: string;
|
130 |
+
}) {
|
131 |
+
const { originUrl, mode: globalMode } =
|
132 |
+
this.configService.get<ConfigurationType['feed']>('feed')!;
|
133 |
+
|
134 |
+
const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;
|
135 |
+
|
136 |
+
const feed = new Feed({
|
137 |
+
title: feedInfo.mpName,
|
138 |
+
description: feedInfo.mpIntro,
|
139 |
+
id: link,
|
140 |
+
link: link,
|
141 |
+
language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
|
142 |
+
image: feedInfo.mpCover,
|
143 |
+
favicon: feedInfo.mpCover,
|
144 |
+
copyright: '',
|
145 |
+
updated: new Date(feedInfo.updateTime * 1e3),
|
146 |
+
generator: 'WeWe-RSS',
|
147 |
+
});
|
148 |
+
|
149 |
+
feed.addExtension({
|
150 |
+
name: 'generator',
|
151 |
+
objects: `WeWe-RSS`,
|
152 |
+
});
|
153 |
+
|
154 |
+
/**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/
|
155 |
+
const enableFullText =
|
156 |
+
typeof mode === 'string'
|
157 |
+
? mode === 'fulltext'
|
158 |
+
: globalMode === 'fulltext';
|
159 |
+
|
160 |
+
const mapper = async (item) => {
|
161 |
+
const { title, id, publishTime, picUrl } = item;
|
162 |
+
const link = `https://mp.weixin.qq.com/s/${id}`;
|
163 |
+
|
164 |
+
const published = new Date(publishTime * 1e3);
|
165 |
+
|
166 |
+
let description = '';
|
167 |
+
if (enableFullText) {
|
168 |
+
description = await this.tryGetContent(id);
|
169 |
+
}
|
170 |
+
|
171 |
+
feed.addItem({
|
172 |
+
id,
|
173 |
+
title,
|
174 |
+
link: link,
|
175 |
+
guid: link,
|
176 |
+
description,
|
177 |
+
date: published,
|
178 |
+
image: picUrl,
|
179 |
+
});
|
180 |
+
};
|
181 |
+
|
182 |
+
await pMap(articles, mapper, { concurrency: 2, stopOnError: false });
|
183 |
+
|
184 |
+
return feed;
|
185 |
+
}
|
186 |
+
|
187 |
+
async handleGenerateFeed({
|
188 |
+
id,
|
189 |
+
type,
|
190 |
+
limit,
|
191 |
+
mode,
|
192 |
+
}: {
|
193 |
+
id?: string;
|
194 |
+
type: string;
|
195 |
+
limit: number;
|
196 |
+
mode?: string;
|
197 |
+
}) {
|
198 |
+
if (!feedTypes.includes(type as any)) {
|
199 |
+
type = 'atom';
|
200 |
+
}
|
201 |
+
|
202 |
+
let articles: Article[];
|
203 |
+
let feedInfo: FeedInfo;
|
204 |
+
if (id) {
|
205 |
+
feedInfo = (await this.prismaService.feed.findFirst({
|
206 |
+
where: { id },
|
207 |
+
}))!;
|
208 |
+
|
209 |
+
if (!feedInfo) {
|
210 |
+
throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST);
|
211 |
+
}
|
212 |
+
|
213 |
+
articles = await this.prismaService.article.findMany({
|
214 |
+
where: { mpId: id },
|
215 |
+
orderBy: { publishTime: 'desc' },
|
216 |
+
take: limit,
|
217 |
+
});
|
218 |
+
} else {
|
219 |
+
articles = await this.prismaService.article.findMany({
|
220 |
+
orderBy: { publishTime: 'desc' },
|
221 |
+
take: limit,
|
222 |
+
});
|
223 |
+
|
224 |
+
feedInfo = {
|
225 |
+
id: 'all',
|
226 |
+
mpName: 'WeWe-RSS 全部文章',
|
227 |
+
mpIntro: 'WeWe-RSS',
|
228 |
+
mpCover: 'https://r2-assets.111965.xyz/wewe-rss.png',
|
229 |
+
status: 1,
|
230 |
+
syncTime: 0,
|
231 |
+
updateTime: Math.floor(Date.now() / 1e3),
|
232 |
+
createdAt: new Date(),
|
233 |
+
updatedAt: new Date(),
|
234 |
+
};
|
235 |
+
}
|
236 |
+
|
237 |
+
this.logger.log('handleGenerateFeed articles: ' + articles.length);
|
238 |
+
const feed = await this.renderFeed({ feedInfo, articles, type, mode });
|
239 |
+
|
240 |
+
switch (type) {
|
241 |
+
case 'rss':
|
242 |
+
return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };
|
243 |
+
case 'json':
|
244 |
+
return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };
|
245 |
+
case 'atom':
|
246 |
+
default:
|
247 |
+
return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };
|
248 |
+
}
|
249 |
+
}
|
250 |
+
|
251 |
+
async getFeedList() {
|
252 |
+
const data = await this.prismaService.feed.findMany();
|
253 |
+
|
254 |
+
return data.map((item) => {
|
255 |
+
return {
|
256 |
+
id: item.id,
|
257 |
+
name: item.mpName,
|
258 |
+
intro: item.mpIntro,
|
259 |
+
cover: item.mpCover,
|
260 |
+
syncTime: item.syncTime,
|
261 |
+
updateTime: item.updateTime,
|
262 |
+
};
|
263 |
+
});
|
264 |
+
}
|
265 |
+
}
|
apps/server/src/main.ts
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NestFactory } from '@nestjs/core';
|
2 |
+
import { AppModule } from './app.module';
|
3 |
+
import { TrpcRouter } from '@server/trpc/trpc.router';
|
4 |
+
import { ConfigService } from '@nestjs/config';
|
5 |
+
import { json, urlencoded } from 'express';
|
6 |
+
import { NestExpressApplication } from '@nestjs/platform-express';
|
7 |
+
import { ConfigurationType } from './configuration';
|
8 |
+
import { join, resolve } from 'path';
|
9 |
+
import { readFileSync } from 'fs';
|
10 |
+
|
11 |
+
const packageJson = JSON.parse(
|
12 |
+
readFileSync(resolve(__dirname, '..', './package.json'), 'utf-8'),
|
13 |
+
);
|
14 |
+
|
15 |
+
const appVersion = packageJson.version;
|
16 |
+
console.log('appVersion: v' + appVersion);
|
17 |
+
|
18 |
+
async function bootstrap() {
|
19 |
+
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
20 |
+
const configService = app.get(ConfigService);
|
21 |
+
|
22 |
+
const { host, isProd, port } =
|
23 |
+
configService.get<ConfigurationType['server']>('server')!;
|
24 |
+
|
25 |
+
app.use(json({ limit: '10mb' }));
|
26 |
+
app.use(urlencoded({ extended: true, limit: '10mb' }));
|
27 |
+
|
28 |
+
app.useStaticAssets(join(__dirname, '..', 'client', 'assets'), {
|
29 |
+
prefix: '/dash/assets/',
|
30 |
+
});
|
31 |
+
app.setBaseViewsDir(join(__dirname, '..', 'client'));
|
32 |
+
app.setViewEngine('hbs');
|
33 |
+
|
34 |
+
if (isProd) {
|
35 |
+
app.enable('trust proxy');
|
36 |
+
}
|
37 |
+
|
38 |
+
app.enableCors({
|
39 |
+
exposedHeaders: ['authorization'],
|
40 |
+
});
|
41 |
+
|
42 |
+
const trpc = app.get(TrpcRouter);
|
43 |
+
trpc.applyMiddleware(app);
|
44 |
+
|
45 |
+
await app.listen(port, host);
|
46 |
+
|
47 |
+
console.log(`Server is running at http://${host}:${port}`);
|
48 |
+
}
|
49 |
+
bootstrap();
|
apps/server/src/prisma/prisma.module.ts
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Module } from '@nestjs/common';
|
2 |
+
import { PrismaService } from './prisma.service';
|
3 |
+
|
4 |
+
@Module({
|
5 |
+
providers: [PrismaService],
|
6 |
+
exports: [PrismaService],
|
7 |
+
})
|
8 |
+
export class PrismaModule {}
|
apps/server/src/prisma/prisma.service.ts
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Injectable, OnModuleInit } from '@nestjs/common';
|
2 |
+
import { PrismaClient } from '@prisma/client';
|
3 |
+
|
4 |
+
@Injectable()
|
5 |
+
export class PrismaService extends PrismaClient implements OnModuleInit {
|
6 |
+
async onModuleInit() {
|
7 |
+
await this.$connect();
|
8 |
+
}
|
9 |
+
}
|
apps/server/src/trpc/trpc.module.ts
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Module } from '@nestjs/common';
|
2 |
+
import { TrpcService } from '@server/trpc/trpc.service';
|
3 |
+
import { TrpcRouter } from '@server/trpc/trpc.router';
|
4 |
+
import { PrismaModule } from '@server/prisma/prisma.module';
|
5 |
+
|
6 |
+
@Module({
|
7 |
+
imports: [PrismaModule],
|
8 |
+
controllers: [],
|
9 |
+
providers: [TrpcService, TrpcRouter],
|
10 |
+
exports: [TrpcService, TrpcRouter],
|
11 |
+
})
|
12 |
+
export class TrpcModule {}
|
apps/server/src/trpc/trpc.router.ts
ADDED
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { INestApplication, Injectable, Logger } from '@nestjs/common';
|
2 |
+
import { z } from 'zod';
|
3 |
+
import { TrpcService } from '@server/trpc/trpc.service';
|
4 |
+
import * as trpcExpress from '@trpc/server/adapters/express';
|
5 |
+
import { TRPCError } from '@trpc/server';
|
6 |
+
import { PrismaService } from '@server/prisma/prisma.service';
|
7 |
+
import { statusMap } from '@server/constants';
|
8 |
+
import { ConfigService } from '@nestjs/config';
|
9 |
+
import { ConfigurationType } from '@server/configuration';
|
10 |
+
|
11 |
+
@Injectable()
|
12 |
+
export class TrpcRouter {
|
13 |
+
constructor(
|
14 |
+
private readonly trpcService: TrpcService,
|
15 |
+
private readonly prismaService: PrismaService,
|
16 |
+
private readonly configService: ConfigService,
|
17 |
+
) {}
|
18 |
+
|
19 |
+
private readonly logger = new Logger(this.constructor.name);
|
20 |
+
|
21 |
+
accountRouter = this.trpcService.router({
|
22 |
+
list: this.trpcService.protectedProcedure
|
23 |
+
.input(
|
24 |
+
z.object({
|
25 |
+
limit: z.number().min(1).max(100).nullish(),
|
26 |
+
cursor: z.string().nullish(),
|
27 |
+
}),
|
28 |
+
)
|
29 |
+
.query(async ({ input }) => {
|
30 |
+
const limit = input.limit ?? 50;
|
31 |
+
const { cursor } = input;
|
32 |
+
|
33 |
+
const items = await this.prismaService.account.findMany({
|
34 |
+
take: limit + 1,
|
35 |
+
where: {},
|
36 |
+
select: {
|
37 |
+
id: true,
|
38 |
+
name: true,
|
39 |
+
status: true,
|
40 |
+
createdAt: true,
|
41 |
+
updatedAt: true,
|
42 |
+
token: false,
|
43 |
+
},
|
44 |
+
cursor: cursor
|
45 |
+
? {
|
46 |
+
id: cursor,
|
47 |
+
}
|
48 |
+
: undefined,
|
49 |
+
orderBy: {
|
50 |
+
createdAt: 'asc',
|
51 |
+
},
|
52 |
+
});
|
53 |
+
let nextCursor: typeof cursor | undefined = undefined;
|
54 |
+
if (items.length > limit) {
|
55 |
+
// Remove the last item and use it as next cursor
|
56 |
+
|
57 |
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
58 |
+
const nextItem = items.pop()!;
|
59 |
+
nextCursor = nextItem.id;
|
60 |
+
}
|
61 |
+
|
62 |
+
const disabledAccounts = this.trpcService.getBlockedAccountIds();
|
63 |
+
return {
|
64 |
+
blocks: disabledAccounts,
|
65 |
+
items,
|
66 |
+
nextCursor,
|
67 |
+
};
|
68 |
+
}),
|
69 |
+
byId: this.trpcService.protectedProcedure
|
70 |
+
.input(z.string())
|
71 |
+
.query(async ({ input: id }) => {
|
72 |
+
const account = await this.prismaService.account.findUnique({
|
73 |
+
where: { id },
|
74 |
+
});
|
75 |
+
if (!account) {
|
76 |
+
throw new TRPCError({
|
77 |
+
code: 'BAD_REQUEST',
|
78 |
+
message: `No account with id '${id}'`,
|
79 |
+
});
|
80 |
+
}
|
81 |
+
return account;
|
82 |
+
}),
|
83 |
+
add: this.trpcService.protectedProcedure
|
84 |
+
.input(
|
85 |
+
z.object({
|
86 |
+
id: z.string().min(1).max(32),
|
87 |
+
token: z.string().min(1),
|
88 |
+
name: z.string().min(1),
|
89 |
+
status: z.number().default(statusMap.ENABLE),
|
90 |
+
}),
|
91 |
+
)
|
92 |
+
.mutation(async ({ input }) => {
|
93 |
+
const { id, ...data } = input;
|
94 |
+
const account = await this.prismaService.account.upsert({
|
95 |
+
where: {
|
96 |
+
id,
|
97 |
+
},
|
98 |
+
update: data,
|
99 |
+
create: input,
|
100 |
+
});
|
101 |
+
|
102 |
+
return account;
|
103 |
+
}),
|
104 |
+
edit: this.trpcService.protectedProcedure
|
105 |
+
.input(
|
106 |
+
z.object({
|
107 |
+
id: z.string(),
|
108 |
+
data: z.object({
|
109 |
+
token: z.string().min(1).optional(),
|
110 |
+
name: z.string().min(1).optional(),
|
111 |
+
status: z.number().optional(),
|
112 |
+
}),
|
113 |
+
}),
|
114 |
+
)
|
115 |
+
.mutation(async ({ input }) => {
|
116 |
+
const { id, data } = input;
|
117 |
+
const account = await this.prismaService.account.update({
|
118 |
+
where: { id },
|
119 |
+
data,
|
120 |
+
});
|
121 |
+
return account;
|
122 |
+
}),
|
123 |
+
delete: this.trpcService.protectedProcedure
|
124 |
+
.input(z.string())
|
125 |
+
.mutation(async ({ input: id }) => {
|
126 |
+
await this.prismaService.account.delete({ where: { id } });
|
127 |
+
return id;
|
128 |
+
}),
|
129 |
+
});
|
130 |
+
|
131 |
+
feedRouter = this.trpcService.router({
|
132 |
+
list: this.trpcService.protectedProcedure
|
133 |
+
.input(
|
134 |
+
z.object({
|
135 |
+
limit: z.number().min(1).max(100).nullish(),
|
136 |
+
cursor: z.string().nullish(),
|
137 |
+
}),
|
138 |
+
)
|
139 |
+
.query(async ({ input }) => {
|
140 |
+
const limit = input.limit ?? 50;
|
141 |
+
const { cursor } = input;
|
142 |
+
|
143 |
+
const items = await this.prismaService.feed.findMany({
|
144 |
+
take: limit + 1,
|
145 |
+
where: {},
|
146 |
+
cursor: cursor
|
147 |
+
? {
|
148 |
+
id: cursor,
|
149 |
+
}
|
150 |
+
: undefined,
|
151 |
+
orderBy: {
|
152 |
+
createdAt: 'asc',
|
153 |
+
},
|
154 |
+
});
|
155 |
+
let nextCursor: typeof cursor | undefined = undefined;
|
156 |
+
if (items.length > limit) {
|
157 |
+
// Remove the last item and use it as next cursor
|
158 |
+
|
159 |
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
160 |
+
const nextItem = items.pop()!;
|
161 |
+
nextCursor = nextItem.id;
|
162 |
+
}
|
163 |
+
|
164 |
+
return {
|
165 |
+
items: items,
|
166 |
+
nextCursor,
|
167 |
+
};
|
168 |
+
}),
|
169 |
+
byId: this.trpcService.protectedProcedure
|
170 |
+
.input(z.string())
|
171 |
+
.query(async ({ input: id }) => {
|
172 |
+
const feed = await this.prismaService.feed.findUnique({
|
173 |
+
where: { id },
|
174 |
+
});
|
175 |
+
if (!feed) {
|
176 |
+
throw new TRPCError({
|
177 |
+
code: 'BAD_REQUEST',
|
178 |
+
message: `No feed with id '${id}'`,
|
179 |
+
});
|
180 |
+
}
|
181 |
+
return feed;
|
182 |
+
}),
|
183 |
+
add: this.trpcService.protectedProcedure
|
184 |
+
.input(
|
185 |
+
z.object({
|
186 |
+
id: z.string(),
|
187 |
+
mpName: z.string(),
|
188 |
+
mpCover: z.string(),
|
189 |
+
mpIntro: z.string(),
|
190 |
+
syncTime: z
|
191 |
+
.number()
|
192 |
+
.optional()
|
193 |
+
.default(Math.floor(Date.now() / 1e3)),
|
194 |
+
updateTime: z.number(),
|
195 |
+
status: z.number().default(statusMap.ENABLE),
|
196 |
+
}),
|
197 |
+
)
|
198 |
+
.mutation(async ({ input }) => {
|
199 |
+
const { id, ...data } = input;
|
200 |
+
const feed = await this.prismaService.feed.upsert({
|
201 |
+
where: {
|
202 |
+
id,
|
203 |
+
},
|
204 |
+
update: data,
|
205 |
+
create: input,
|
206 |
+
});
|
207 |
+
|
208 |
+
return feed;
|
209 |
+
}),
|
210 |
+
edit: this.trpcService.protectedProcedure
|
211 |
+
.input(
|
212 |
+
z.object({
|
213 |
+
id: z.string(),
|
214 |
+
data: z.object({
|
215 |
+
mpName: z.string().optional(),
|
216 |
+
mpCover: z.string().optional(),
|
217 |
+
mpIntro: z.string().optional(),
|
218 |
+
syncTime: z.number().optional(),
|
219 |
+
updateTime: z.number().optional(),
|
220 |
+
status: z.number().optional(),
|
221 |
+
}),
|
222 |
+
}),
|
223 |
+
)
|
224 |
+
.mutation(async ({ input }) => {
|
225 |
+
const { id, data } = input;
|
226 |
+
const feed = await this.prismaService.feed.update({
|
227 |
+
where: { id },
|
228 |
+
data,
|
229 |
+
});
|
230 |
+
return feed;
|
231 |
+
}),
|
232 |
+
delete: this.trpcService.protectedProcedure
|
233 |
+
.input(z.string())
|
234 |
+
.mutation(async ({ input: id }) => {
|
235 |
+
await this.prismaService.feed.delete({ where: { id } });
|
236 |
+
return id;
|
237 |
+
}),
|
238 |
+
|
239 |
+
refreshArticles: this.trpcService.protectedProcedure
|
240 |
+
.input(z.string())
|
241 |
+
.mutation(async ({ input: mpId }) => {
|
242 |
+
await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId);
|
243 |
+
}),
|
244 |
+
});
|
245 |
+
|
246 |
+
articleRouter = this.trpcService.router({
|
247 |
+
list: this.trpcService.protectedProcedure
|
248 |
+
.input(
|
249 |
+
z.object({
|
250 |
+
limit: z.number().min(1).max(100).nullish(),
|
251 |
+
cursor: z.string().nullish(),
|
252 |
+
mpId: z.string().nullish(),
|
253 |
+
}),
|
254 |
+
)
|
255 |
+
.query(async ({ input }) => {
|
256 |
+
const limit = input.limit ?? 50;
|
257 |
+
const { cursor, mpId } = input;
|
258 |
+
|
259 |
+
const items = await this.prismaService.article.findMany({
|
260 |
+
orderBy: [
|
261 |
+
{
|
262 |
+
publishTime: 'desc',
|
263 |
+
},
|
264 |
+
],
|
265 |
+
take: limit + 1,
|
266 |
+
where: mpId ? { mpId } : undefined,
|
267 |
+
cursor: cursor
|
268 |
+
? {
|
269 |
+
id: cursor,
|
270 |
+
}
|
271 |
+
: undefined,
|
272 |
+
});
|
273 |
+
let nextCursor: typeof cursor | undefined = undefined;
|
274 |
+
if (items.length > limit) {
|
275 |
+
// Remove the last item and use it as next cursor
|
276 |
+
|
277 |
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
278 |
+
const nextItem = items.pop()!;
|
279 |
+
nextCursor = nextItem.id;
|
280 |
+
}
|
281 |
+
|
282 |
+
return {
|
283 |
+
items,
|
284 |
+
nextCursor,
|
285 |
+
};
|
286 |
+
}),
|
287 |
+
byId: this.trpcService.protectedProcedure
|
288 |
+
.input(z.string())
|
289 |
+
.query(async ({ input: id }) => {
|
290 |
+
const article = await this.prismaService.article.findUnique({
|
291 |
+
where: { id },
|
292 |
+
});
|
293 |
+
if (!article) {
|
294 |
+
throw new TRPCError({
|
295 |
+
code: 'BAD_REQUEST',
|
296 |
+
message: `No article with id '${id}'`,
|
297 |
+
});
|
298 |
+
}
|
299 |
+
return article;
|
300 |
+
}),
|
301 |
+
|
302 |
+
add: this.trpcService.protectedProcedure
|
303 |
+
.input(
|
304 |
+
z.object({
|
305 |
+
id: z.string(),
|
306 |
+
mpId: z.string(),
|
307 |
+
title: z.string(),
|
308 |
+
picUrl: z.string().optional().default(''),
|
309 |
+
publishTime: z.number(),
|
310 |
+
}),
|
311 |
+
)
|
312 |
+
.mutation(async ({ input }) => {
|
313 |
+
const { id, ...data } = input;
|
314 |
+
const article = await this.prismaService.article.upsert({
|
315 |
+
where: {
|
316 |
+
id,
|
317 |
+
},
|
318 |
+
update: data,
|
319 |
+
create: input,
|
320 |
+
});
|
321 |
+
|
322 |
+
return article;
|
323 |
+
}),
|
324 |
+
delete: this.trpcService.protectedProcedure
|
325 |
+
.input(z.string())
|
326 |
+
.mutation(async ({ input: id }) => {
|
327 |
+
await this.prismaService.article.delete({ where: { id } });
|
328 |
+
return id;
|
329 |
+
}),
|
330 |
+
});
|
331 |
+
|
332 |
+
platformRouter = this.trpcService.router({
|
333 |
+
getMpArticles: this.trpcService.protectedProcedure
|
334 |
+
.input(
|
335 |
+
z.object({
|
336 |
+
mpId: z.string(),
|
337 |
+
}),
|
338 |
+
)
|
339 |
+
.mutation(async ({ input: { mpId } }) => {
|
340 |
+
try {
|
341 |
+
const results = await this.trpcService.getMpArticles(mpId);
|
342 |
+
return results;
|
343 |
+
} catch (err: any) {
|
344 |
+
this.logger.log('getMpArticles err: ', err);
|
345 |
+
throw new TRPCError({
|
346 |
+
code: 'INTERNAL_SERVER_ERROR',
|
347 |
+
message: err.response?.data?.message || err.message,
|
348 |
+
cause: err.stack,
|
349 |
+
});
|
350 |
+
}
|
351 |
+
}),
|
352 |
+
getMpInfo: this.trpcService.protectedProcedure
|
353 |
+
.input(
|
354 |
+
z.object({
|
355 |
+
wxsLink: z
|
356 |
+
.string()
|
357 |
+
.refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')),
|
358 |
+
}),
|
359 |
+
)
|
360 |
+
.mutation(async ({ input: { wxsLink: url } }) => {
|
361 |
+
try {
|
362 |
+
const results = await this.trpcService.getMpInfo(url);
|
363 |
+
return results;
|
364 |
+
} catch (err: any) {
|
365 |
+
this.logger.log('getMpInfo err: ', err);
|
366 |
+
throw new TRPCError({
|
367 |
+
code: 'INTERNAL_SERVER_ERROR',
|
368 |
+
message: err.response?.data?.message || err.message,
|
369 |
+
cause: err.stack,
|
370 |
+
});
|
371 |
+
}
|
372 |
+
}),
|
373 |
+
|
374 |
+
createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => {
|
375 |
+
return this.trpcService.createLoginUrl();
|
376 |
+
}),
|
377 |
+
getLoginResult: this.trpcService.protectedProcedure
|
378 |
+
.input(
|
379 |
+
z.object({
|
380 |
+
id: z.string(),
|
381 |
+
}),
|
382 |
+
)
|
383 |
+
.query(async ({ input }) => {
|
384 |
+
return this.trpcService.getLoginResult(input.id);
|
385 |
+
}),
|
386 |
+
});
|
387 |
+
|
388 |
+
appRouter = this.trpcService.router({
|
389 |
+
feed: this.feedRouter,
|
390 |
+
account: this.accountRouter,
|
391 |
+
article: this.articleRouter,
|
392 |
+
platform: this.platformRouter,
|
393 |
+
});
|
394 |
+
|
395 |
+
async applyMiddleware(app: INestApplication) {
|
396 |
+
app.use(
|
397 |
+
`/trpc`,
|
398 |
+
trpcExpress.createExpressMiddleware({
|
399 |
+
router: this.appRouter,
|
400 |
+
createContext: ({ req }) => {
|
401 |
+
const authCode =
|
402 |
+
this.configService.get<ConfigurationType['auth']>('auth')!.code;
|
403 |
+
|
404 |
+
if (req.headers.authorization !== authCode) {
|
405 |
+
return {
|
406 |
+
errorMsg: 'authCode不正确!',
|
407 |
+
};
|
408 |
+
}
|
409 |
+
return {
|
410 |
+
errorMsg: null,
|
411 |
+
};
|
412 |
+
},
|
413 |
+
middleware: (req, res, next) => {
|
414 |
+
next();
|
415 |
+
},
|
416 |
+
}),
|
417 |
+
);
|
418 |
+
}
|
419 |
+
}
|
420 |
+
|
421 |
+
export type AppRouter = TrpcRouter[`appRouter`];
|
apps/server/src/trpc/trpc.service.ts
ADDED
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Injectable, Logger } from '@nestjs/common';
|
2 |
+
import { ConfigService } from '@nestjs/config';
|
3 |
+
import { ConfigurationType } from '@server/configuration';
|
4 |
+
import { statusMap } from '@server/constants';
|
5 |
+
import { PrismaService } from '@server/prisma/prisma.service';
|
6 |
+
import { TRPCError, initTRPC } from '@trpc/server';
|
7 |
+
import Axios, { AxiosInstance } from 'axios';
|
8 |
+
import dayjs from 'dayjs';
|
9 |
+
import timezone from 'dayjs/plugin/timezone';
|
10 |
+
import utc from 'dayjs/plugin/utc';
|
11 |
+
|
12 |
+
dayjs.extend(utc);
|
13 |
+
dayjs.extend(timezone);
|
14 |
+
|
15 |
+
/**
|
16 |
+
* 读书账号每日小黑屋
|
17 |
+
*/
|
18 |
+
const blockedAccountsMap = new Map<string, string[]>();
|
19 |
+
|
20 |
+
@Injectable()
|
21 |
+
export class TrpcService {
|
22 |
+
trpc = initTRPC.create();
|
23 |
+
publicProcedure = this.trpc.procedure;
|
24 |
+
protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => {
|
25 |
+
const errorMsg = (ctx as any).errorMsg;
|
26 |
+
if (errorMsg) {
|
27 |
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg });
|
28 |
+
}
|
29 |
+
return next({ ctx });
|
30 |
+
});
|
31 |
+
router = this.trpc.router;
|
32 |
+
mergeRouters = this.trpc.mergeRouters;
|
33 |
+
request: AxiosInstance;
|
34 |
+
|
35 |
+
private readonly logger = new Logger(this.constructor.name);
|
36 |
+
|
37 |
+
constructor(
|
38 |
+
private readonly prismaService: PrismaService,
|
39 |
+
private readonly configService: ConfigService,
|
40 |
+
) {
|
41 |
+
const { url } =
|
42 |
+
this.configService.get<ConfigurationType['platform']>('platform')!;
|
43 |
+
this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 });
|
44 |
+
|
45 |
+
this.request.interceptors.response.use(
|
46 |
+
(response) => {
|
47 |
+
return response;
|
48 |
+
},
|
49 |
+
async (error) => {
|
50 |
+
this.logger.log('error: ', error);
|
51 |
+
const errMsg = error.response?.data?.message || '';
|
52 |
+
|
53 |
+
const id = (error.config.headers as any).xid;
|
54 |
+
if (errMsg.includes('WeReadError401')) {
|
55 |
+
// 账号失效
|
56 |
+
await this.prismaService.account.update({
|
57 |
+
where: { id },
|
58 |
+
data: { status: statusMap.INVALID },
|
59 |
+
});
|
60 |
+
this.logger.error(`账号(${id})登录失效,已禁用`);
|
61 |
+
} else {
|
62 |
+
if (errMsg.includes('WeReadError400')) {
|
63 |
+
// TODO 处理请求参数出错,可能是账号被限制导致的
|
64 |
+
this.logger.error(
|
65 |
+
`账号(${id})处理请求参数出错,可能是账号被限制导致的,打入小黑屋`,
|
66 |
+
);
|
67 |
+
this.logger.error('WeReadError400: ', errMsg);
|
68 |
+
} else if (errMsg.includes('WeReadError429')) {
|
69 |
+
//TODO 处理请求频繁
|
70 |
+
this.logger.error(`账号(${id})请求频繁,打入小黑屋`);
|
71 |
+
}
|
72 |
+
|
73 |
+
const today = this.getTodayDate();
|
74 |
+
|
75 |
+
const blockedAccounts = blockedAccountsMap.get(today);
|
76 |
+
|
77 |
+
if (Array.isArray(blockedAccounts)) {
|
78 |
+
blockedAccounts.push(id);
|
79 |
+
blockedAccountsMap.set(today, blockedAccounts);
|
80 |
+
} else {
|
81 |
+
blockedAccountsMap.set(today, [id]);
|
82 |
+
}
|
83 |
+
}
|
84 |
+
|
85 |
+
return Promise.reject(error);
|
86 |
+
},
|
87 |
+
);
|
88 |
+
}
|
89 |
+
|
90 |
+
private getTodayDate() {
|
91 |
+
return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD');
|
92 |
+
}
|
93 |
+
|
94 |
+
getBlockedAccountIds() {
|
95 |
+
const today = this.getTodayDate();
|
96 |
+
const disabledAccounts = blockedAccountsMap.get(today) || [];
|
97 |
+
this.logger.debug('disabledAccounts: ', disabledAccounts);
|
98 |
+
return disabledAccounts.filter(Boolean);
|
99 |
+
}
|
100 |
+
|
101 |
+
private async getAvailableAccount() {
|
102 |
+
const disabledAccounts = this.getBlockedAccountIds();
|
103 |
+
const account = await this.prismaService.account.findFirst({
|
104 |
+
where: {
|
105 |
+
status: statusMap.ENABLE,
|
106 |
+
NOT: {
|
107 |
+
id: { in: disabledAccounts },
|
108 |
+
},
|
109 |
+
},
|
110 |
+
});
|
111 |
+
|
112 |
+
if (!account) {
|
113 |
+
throw new Error('暂无可用读书账号!');
|
114 |
+
}
|
115 |
+
|
116 |
+
return account;
|
117 |
+
}
|
118 |
+
|
119 |
+
async getMpArticles(mpId: string) {
|
120 |
+
const account = await this.getAvailableAccount();
|
121 |
+
|
122 |
+
return this.request
|
123 |
+
.get<
|
124 |
+
{
|
125 |
+
id: string;
|
126 |
+
title: string;
|
127 |
+
picUrl: string;
|
128 |
+
publishTime: number;
|
129 |
+
}[]
|
130 |
+
>(`/api/platform/mps/${mpId}/articles`, {
|
131 |
+
headers: {
|
132 |
+
xid: account.id,
|
133 |
+
Authorization: `Bearer ${account.token}`,
|
134 |
+
},
|
135 |
+
})
|
136 |
+
.then((res) => res.data)
|
137 |
+
.then((res) => {
|
138 |
+
this.logger.log(`getMpArticles(${mpId}): ${res.length} articles`);
|
139 |
+
return res;
|
140 |
+
});
|
141 |
+
}
|
142 |
+
|
143 |
+
async refreshMpArticlesAndUpdateFeed(mpId: string) {
|
144 |
+
const articles = await this.getMpArticles(mpId);
|
145 |
+
|
146 |
+
if (articles.length > 0) {
|
147 |
+
let results;
|
148 |
+
const { type } =
|
149 |
+
this.configService.get<ConfigurationType['database']>('database')!;
|
150 |
+
if (type === 'sqlite') {
|
151 |
+
// sqlite3 不支持 createMany
|
152 |
+
const inserts = articles.map(({ id, picUrl, publishTime, title }) =>
|
153 |
+
this.prismaService.article.upsert({
|
154 |
+
create: { id, mpId, picUrl, publishTime, title },
|
155 |
+
update: {
|
156 |
+
publishTime,
|
157 |
+
title,
|
158 |
+
},
|
159 |
+
where: { id },
|
160 |
+
}),
|
161 |
+
);
|
162 |
+
results = await this.prismaService.$transaction(inserts);
|
163 |
+
} else {
|
164 |
+
results = await (this.prismaService.article as any).createMany({
|
165 |
+
data: articles.map(({ id, picUrl, publishTime, title }) => ({
|
166 |
+
id,
|
167 |
+
mpId,
|
168 |
+
picUrl,
|
169 |
+
publishTime,
|
170 |
+
title,
|
171 |
+
})),
|
172 |
+
skipDuplicates: true,
|
173 |
+
});
|
174 |
+
}
|
175 |
+
|
176 |
+
this.logger.debug('refreshMpArticlesAndUpdateFeed results: ', results);
|
177 |
+
}
|
178 |
+
|
179 |
+
await this.prismaService.feed.update({
|
180 |
+
where: { id: mpId },
|
181 |
+
data: {
|
182 |
+
syncTime: Math.floor(Date.now() / 1e3),
|
183 |
+
},
|
184 |
+
});
|
185 |
+
}
|
186 |
+
|
187 |
+
async getMpInfo(url: string) {
|
188 |
+
const account = await this.getAvailableAccount();
|
189 |
+
|
190 |
+
return this.request
|
191 |
+
.post<
|
192 |
+
{
|
193 |
+
id: string;
|
194 |
+
cover: string;
|
195 |
+
name: string;
|
196 |
+
intro: string;
|
197 |
+
updateTime: number;
|
198 |
+
}[]
|
199 |
+
>(
|
200 |
+
`/api/platform/wxs2mp`,
|
201 |
+
{ url },
|
202 |
+
{
|
203 |
+
headers: {
|
204 |
+
xid: account.id,
|
205 |
+
Authorization: `Bearer ${account.token}`,
|
206 |
+
},
|
207 |
+
},
|
208 |
+
)
|
209 |
+
.then((res) => res.data);
|
210 |
+
}
|
211 |
+
|
212 |
+
async createLoginUrl() {
|
213 |
+
return this.request
|
214 |
+
.post<{
|
215 |
+
uuid: string;
|
216 |
+
scanUrl: string;
|
217 |
+
}>(`/api/login/platform`)
|
218 |
+
.then((res) => res.data);
|
219 |
+
}
|
220 |
+
|
221 |
+
async getLoginResult(id: string) {
|
222 |
+
return this.request
|
223 |
+
.get<{
|
224 |
+
message: 'waiting' | 'success';
|
225 |
+
vid?: number;
|
226 |
+
token?: string;
|
227 |
+
username?: string;
|
228 |
+
}>(`/api/login/platform/${id}`)
|
229 |
+
.then((res) => res.data);
|
230 |
+
}
|
231 |
+
}
|
apps/server/test/app.e2e-spec.ts
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Test, TestingModule } from '@nestjs/testing';
|
2 |
+
import { INestApplication } from '@nestjs/common';
|
3 |
+
import * as request from 'supertest';
|
4 |
+
import { AppModule } from './../src/app.module';
|
5 |
+
|
6 |
+
describe('AppController (e2e)', () => {
|
7 |
+
let app: INestApplication;
|
8 |
+
|
9 |
+
beforeEach(async () => {
|
10 |
+
const moduleFixture: TestingModule = await Test.createTestingModule({
|
11 |
+
imports: [AppModule],
|
12 |
+
}).compile();
|
13 |
+
|
14 |
+
app = moduleFixture.createNestApplication();
|
15 |
+
await app.init();
|
16 |
+
});
|
17 |
+
|
18 |
+
it('/ (GET)', () => {
|
19 |
+
return request(app.getHttpServer())
|
20 |
+
.get('/')
|
21 |
+
.expect(200)
|
22 |
+
.expect('Hello World!');
|
23 |
+
});
|
24 |
+
});
|
apps/server/test/jest-e2e.json
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"moduleFileExtensions": ["js", "json", "ts"],
|
3 |
+
"rootDir": ".",
|
4 |
+
"testEnvironment": "node",
|
5 |
+
"testRegex": ".e2e-spec.ts$",
|
6 |
+
"transform": {
|
7 |
+
"^.+\\.(t|j)s$": "ts-jest"
|
8 |
+
}
|
9 |
+
}
|
apps/server/tsconfig.build.json
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"extends": "./tsconfig.json",
|
3 |
+
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
4 |
+
}
|
apps/server/tsconfig.json
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"extends": "../../tsconfig.json",
|
3 |
+
"compilerOptions": {
|
4 |
+
"module": "commonjs",
|
5 |
+
"declaration": true,
|
6 |
+
"removeComments": true,
|
7 |
+
"allowSyntheticDefaultImports": true,
|
8 |
+
"target": "ES2021",
|
9 |
+
"sourceMap": true,
|
10 |
+
"outDir": "./dist",
|
11 |
+
"esModuleInterop":true
|
12 |
+
}
|
13 |
+
}
|
apps/web/.env.local.example
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
# 同SERVER_ORIGIN_URL
|
2 |
+
VITE_SERVER_ORIGIN_URL=http://localhost:4000
|
apps/web/.eslintrc.cjs
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
module.exports = {
|
2 |
+
root: true,
|
3 |
+
env: { browser: true, es2020: true },
|
4 |
+
extends: [
|
5 |
+
'eslint:recommended',
|
6 |
+
'plugin:@typescript-eslint/recommended',
|
7 |
+
'plugin:react-hooks/recommended',
|
8 |
+
],
|
9 |
+
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
10 |
+
parser: '@typescript-eslint/parser',
|
11 |
+
plugins: ['react-refresh'],
|
12 |
+
rules: {
|
13 |
+
'react-refresh/only-export-components': [
|
14 |
+
'warn',
|
15 |
+
{ allowConstantExport: true },
|
16 |
+
],
|
17 |
+
'@typescript-eslint/no-explicit-any': 'warn',
|
18 |
+
},
|
19 |
+
};
|
apps/web/.gitignore
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Logs
|
2 |
+
logs
|
3 |
+
*.log
|
4 |
+
npm-debug.log*
|
5 |
+
yarn-debug.log*
|
6 |
+
yarn-error.log*
|
7 |
+
pnpm-debug.log*
|
8 |
+
lerna-debug.log*
|
9 |
+
|
10 |
+
node_modules
|
11 |
+
dist
|
12 |
+
dist-ssr
|
13 |
+
*.local
|
14 |
+
|
15 |
+
# Editor directories and files
|
16 |
+
.vscode/*
|
17 |
+
!.vscode/extensions.json
|
18 |
+
.idea
|
19 |
+
.DS_Store
|
20 |
+
*.suo
|
21 |
+
*.ntvs*
|
22 |
+
*.njsproj
|
23 |
+
*.sln
|
24 |
+
*.sw?
|
apps/web/README.md
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# React + TypeScript + Vite
|
2 |
+
|
3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
4 |
+
|
5 |
+
Currently, two official plugins are available:
|
6 |
+
|
7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
9 |
+
|
10 |
+
## Expanding the ESLint configuration
|
11 |
+
|
12 |
+
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
13 |
+
|
14 |
+
- Configure the top-level `parserOptions` property like this:
|
15 |
+
|
16 |
+
```js
|
17 |
+
export default {
|
18 |
+
// other rules...
|
19 |
+
parserOptions: {
|
20 |
+
ecmaVersion: 'latest',
|
21 |
+
sourceType: 'module',
|
22 |
+
project: ['./tsconfig.json', './tsconfig.node.json'],
|
23 |
+
tsconfigRootDir: __dirname,
|
24 |
+
},
|
25 |
+
}
|
26 |
+
```
|
27 |
+
|
28 |
+
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
29 |
+
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
30 |
+
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
apps/web/index.html
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link rel="icon" href="https://r2-assets.111965.xyz/wewe-rss.png" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>WeWe RSS</title>
|
8 |
+
<meta name="description" content="更好的公众号订阅方式" />
|
9 |
+
</head>
|
10 |
+
<body>
|
11 |
+
<div id="root"></div>
|
12 |
+
<script>
|
13 |
+
window.__WEWE_RSS_SERVER_ORIGIN_URL__ = '{{ weweRssServerOriginUrl }}';
|
14 |
+
</script>
|
15 |
+
<script type="module" src="/src/main.tsx"></script>
|
16 |
+
</body>
|
17 |
+
</html>
|
apps/web/package.json
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "web",
|
3 |
+
"private": true,
|
4 |
+
"version": "1.7.0",
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite",
|
8 |
+
"build": "tsc && vite build",
|
9 |
+
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
10 |
+
"preview": "vite preview"
|
11 |
+
},
|
12 |
+
"dependencies": {
|
13 |
+
"@nextui-org/react": "^2.2.9",
|
14 |
+
"@tanstack/react-query": "^4.35.3",
|
15 |
+
"@trpc/client": "^10.45.1",
|
16 |
+
"@trpc/next": "^10.45.1",
|
17 |
+
"@trpc/react-query": "^10.45.1",
|
18 |
+
"autoprefixer": "^10.0.1",
|
19 |
+
"dayjs": "^1.11.10",
|
20 |
+
"framer-motion": "^11.0.5",
|
21 |
+
"next-themes": "^0.2.1",
|
22 |
+
"postcss": "^8",
|
23 |
+
"qrcode.react": "^3.1.0",
|
24 |
+
"react": "^18.2.0",
|
25 |
+
"react-dom": "^18.2.0",
|
26 |
+
"react-router-dom": "^6.22.2",
|
27 |
+
"sonner": "^1.4.0",
|
28 |
+
"tailwindcss": "^3.3.0"
|
29 |
+
},
|
30 |
+
"devDependencies": {
|
31 |
+
"@types/node": "^20.11.24",
|
32 |
+
"@types/react": "^18.2.56",
|
33 |
+
"@types/react-dom": "^18.2.19",
|
34 |
+
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
35 |
+
"@typescript-eslint/parser": "^7.0.2",
|
36 |
+
"@vitejs/plugin-react": "^4.2.1",
|
37 |
+
"eslint": "^8.56.0",
|
38 |
+
"eslint-plugin-react-hooks": "^4.6.0",
|
39 |
+
"eslint-plugin-react-refresh": "^0.4.5",
|
40 |
+
"typescript": "^5.2.2",
|
41 |
+
"vite": "^5.1.4"
|
42 |
+
}
|
43 |
+
}
|
apps/web/postcss.config.js
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export default {
|
2 |
+
plugins: {
|
3 |
+
tailwindcss: {},
|
4 |
+
autoprefixer: {},
|
5 |
+
},
|
6 |
+
}
|
apps/web/src/App.tsx
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
2 |
+
import Feeds from './pages/feeds';
|
3 |
+
import Login from './pages/login';
|
4 |
+
import Accounts from './pages/accounts';
|
5 |
+
import { BaseLayout } from './layouts/base';
|
6 |
+
import { TrpcProvider } from './provider/trpc';
|
7 |
+
import ThemeProvider from './provider/theme';
|
8 |
+
|
9 |
+
function App() {
|
10 |
+
return (
|
11 |
+
<BrowserRouter basename="/dash">
|
12 |
+
<ThemeProvider>
|
13 |
+
<TrpcProvider>
|
14 |
+
<Routes>
|
15 |
+
<Route path="/" element={<BaseLayout />}>
|
16 |
+
<Route index element={<Feeds />} />
|
17 |
+
<Route path="/feeds/:id?" element={<Feeds />} />
|
18 |
+
<Route path="/accounts" element={<Accounts />} />
|
19 |
+
<Route path="/login" element={<Login />} />
|
20 |
+
</Route>
|
21 |
+
</Routes>
|
22 |
+
</TrpcProvider>
|
23 |
+
</ThemeProvider>
|
24 |
+
</BrowserRouter>
|
25 |
+
);
|
26 |
+
}
|
27 |
+
|
28 |
+
export default App;
|
apps/web/src/components/GitHubIcon.tsx
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { IconSvgProps } from '../types';
|
2 |
+
|
3 |
+
export const GitHubIcon = ({
|
4 |
+
size = 24,
|
5 |
+
width,
|
6 |
+
height,
|
7 |
+
...props
|
8 |
+
}: IconSvgProps) => (
|
9 |
+
<svg
|
10 |
+
aria-hidden="true"
|
11 |
+
fill="none"
|
12 |
+
focusable="false"
|
13 |
+
height={size || height}
|
14 |
+
role="presentation"
|
15 |
+
viewBox="0 0 24 24"
|
16 |
+
width={size || width}
|
17 |
+
{...props}
|
18 |
+
>
|
19 |
+
<path
|
20 |
+
clipRule="evenodd"
|
21 |
+
d="M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z"
|
22 |
+
fill="currentColor"
|
23 |
+
fillRule="evenodd"
|
24 |
+
></path>
|
25 |
+
</svg>
|
26 |
+
);
|
apps/web/src/components/Nav.tsx
ADDED
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
Badge,
|
3 |
+
Image,
|
4 |
+
Link,
|
5 |
+
Navbar,
|
6 |
+
NavbarBrand,
|
7 |
+
NavbarContent,
|
8 |
+
NavbarItem,
|
9 |
+
Tooltip,
|
10 |
+
} from '@nextui-org/react';
|
11 |
+
import { ThemeSwitcher } from './ThemeSwitcher';
|
12 |
+
import { GitHubIcon } from './GitHubIcon';
|
13 |
+
import { useLocation } from 'react-router-dom';
|
14 |
+
import { appVersion } from '@web/utils/env';
|
15 |
+
import { useEffect, useState } from 'react';
|
16 |
+
|
17 |
+
const navbarItemLink = [
|
18 |
+
{
|
19 |
+
href: '/feeds',
|
20 |
+
name: '公众号源',
|
21 |
+
},
|
22 |
+
{
|
23 |
+
href: '/accounts',
|
24 |
+
name: '账号管理',
|
25 |
+
},
|
26 |
+
// {
|
27 |
+
// href: '/settings',
|
28 |
+
// name: '设置',
|
29 |
+
// },
|
30 |
+
];
|
31 |
+
|
32 |
+
const Nav = () => {
|
33 |
+
const { pathname } = useLocation();
|
34 |
+
const [releaseVersion, setReleaseVersion] = useState(appVersion);
|
35 |
+
|
36 |
+
useEffect(() => {
|
37 |
+
fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest')
|
38 |
+
.then((res) => res.json())
|
39 |
+
.then((data) => {
|
40 |
+
setReleaseVersion(data.name.replace('v', ''));
|
41 |
+
});
|
42 |
+
}, []);
|
43 |
+
|
44 |
+
const isFoundNewVersion = releaseVersion > appVersion;
|
45 |
+
console.log('isFoundNewVersion: ', isFoundNewVersion);
|
46 |
+
|
47 |
+
return (
|
48 |
+
<div>
|
49 |
+
<Navbar isBordered>
|
50 |
+
<Tooltip
|
51 |
+
content={
|
52 |
+
<div className="p-1">
|
53 |
+
{isFoundNewVersion && (
|
54 |
+
<Link
|
55 |
+
href={`https://github.com/cooderl/wewe-rss/releases/latest`}
|
56 |
+
target="_blank"
|
57 |
+
className="mb-1 block text-medium"
|
58 |
+
>
|
59 |
+
发现新版本:v{releaseVersion}
|
60 |
+
</Link>
|
61 |
+
)}
|
62 |
+
当前版本: v{appVersion}
|
63 |
+
</div>
|
64 |
+
}
|
65 |
+
placement="left"
|
66 |
+
>
|
67 |
+
<NavbarBrand className="cursor-default">
|
68 |
+
<Badge
|
69 |
+
content={isFoundNewVersion ? '' : null}
|
70 |
+
color="danger"
|
71 |
+
size="sm"
|
72 |
+
>
|
73 |
+
<Image
|
74 |
+
width={28}
|
75 |
+
alt="WeWe RSS"
|
76 |
+
className="mr-2"
|
77 |
+
src="https://r2-assets.111965.xyz/wewe-rss.png"
|
78 |
+
></Image>
|
79 |
+
</Badge>
|
80 |
+
<p className="font-bold text-inherit">WeWe RSS</p>
|
81 |
+
</NavbarBrand>
|
82 |
+
</Tooltip>
|
83 |
+
<NavbarContent className="hidden sm:flex gap-4" justify="center">
|
84 |
+
{navbarItemLink.map((item) => {
|
85 |
+
return (
|
86 |
+
<NavbarItem
|
87 |
+
isActive={pathname.startsWith(item.href)}
|
88 |
+
key={item.href}
|
89 |
+
>
|
90 |
+
<Link color="foreground" href={item.href}>
|
91 |
+
{item.name}
|
92 |
+
</Link>
|
93 |
+
</NavbarItem>
|
94 |
+
);
|
95 |
+
})}
|
96 |
+
</NavbarContent>
|
97 |
+
<NavbarContent justify="end">
|
98 |
+
<ThemeSwitcher></ThemeSwitcher>
|
99 |
+
<Link
|
100 |
+
href="https://github.com/cooderl/wewe-rss"
|
101 |
+
target="_blank"
|
102 |
+
color="foreground"
|
103 |
+
>
|
104 |
+
<GitHubIcon />
|
105 |
+
</Link>
|
106 |
+
</NavbarContent>
|
107 |
+
</Navbar>
|
108 |
+
</div>
|
109 |
+
);
|
110 |
+
};
|
111 |
+
|
112 |
+
export default Nav;
|
apps/web/src/components/PlusIcon.tsx
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { IconSvgProps } from '../types';
|
2 |
+
|
3 |
+
export const PlusIcon = ({
|
4 |
+
size = 24,
|
5 |
+
width,
|
6 |
+
height,
|
7 |
+
...props
|
8 |
+
}: IconSvgProps) => (
|
9 |
+
<svg
|
10 |
+
aria-hidden="true"
|
11 |
+
fill="none"
|
12 |
+
focusable="false"
|
13 |
+
height={size || height}
|
14 |
+
role="presentation"
|
15 |
+
viewBox="0 0 24 24"
|
16 |
+
width={size || width}
|
17 |
+
{...props}
|
18 |
+
>
|
19 |
+
<g
|
20 |
+
fill="none"
|
21 |
+
stroke="currentColor"
|
22 |
+
strokeLinecap="round"
|
23 |
+
strokeLinejoin="round"
|
24 |
+
strokeWidth={1.5}
|
25 |
+
>
|
26 |
+
<path d="M6 12h12" />
|
27 |
+
<path d="M12 18V6" />
|
28 |
+
</g>
|
29 |
+
</svg>
|
30 |
+
);
|