iaiuse commited on
Commit
292a7b6
1 Parent(s): 4964329

Upload 91 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +8 -0
  2. .github/workflows/docker-release.yml +142 -0
  3. .gitignore +132 -0
  4. .markdownlint.yaml +9 -0
  5. .npmrc +3 -0
  6. .prettierignore +8 -0
  7. .prettierrc.json +5 -0
  8. .vscode/extensions.json +16 -0
  9. .vscode/settings.json +43 -0
  10. Dockerfile +61 -0
  11. LICENSE +21 -0
  12. apps/server/.env.local.example +28 -0
  13. apps/server/.eslintrc.js +25 -0
  14. apps/server/.gitignore +6 -0
  15. apps/server/.prettierrc.json +5 -0
  16. apps/server/README.md +73 -0
  17. apps/server/docker-bootstrap.sh +8 -0
  18. apps/server/nest-cli.json +8 -0
  19. apps/server/package.json +93 -0
  20. apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql +33 -0
  21. apps/server/prisma-sqlite/migrations/migration_lock.toml +3 -0
  22. apps/server/prisma-sqlite/schema.prisma +56 -0
  23. apps/server/prisma/migrations/20240227153512_init/migration.sql +39 -0
  24. apps/server/prisma/migrations/migration_lock.toml +3 -0
  25. apps/server/prisma/schema.prisma +56 -0
  26. apps/server/src/app.controller.spec.ts +22 -0
  27. apps/server/src/app.controller.ts +39 -0
  28. apps/server/src/app.module.ts +39 -0
  29. apps/server/src/app.service.ts +14 -0
  30. apps/server/src/configuration.ts +35 -0
  31. apps/server/src/constants.ts +14 -0
  32. apps/server/src/feeds/feeds.controller.spec.ts +18 -0
  33. apps/server/src/feeds/feeds.controller.ts +64 -0
  34. apps/server/src/feeds/feeds.module.ts +12 -0
  35. apps/server/src/feeds/feeds.service.spec.ts +18 -0
  36. apps/server/src/feeds/feeds.service.ts +286 -0
  37. apps/server/src/main.ts +49 -0
  38. apps/server/src/prisma/prisma.module.ts +8 -0
  39. apps/server/src/prisma/prisma.service.ts +9 -0
  40. apps/server/src/trpc/trpc.module.ts +12 -0
  41. apps/server/src/trpc/trpc.router.ts +421 -0
  42. apps/server/src/trpc/trpc.service.ts +232 -0
  43. apps/server/test/app.e2e-spec.ts +24 -0
  44. apps/server/test/jest-e2e.json +9 -0
  45. apps/server/tsconfig.build.json +4 -0
  46. apps/server/tsconfig.json +13 -0
  47. apps/web/.env.local.example +2 -0
  48. apps/web/.eslintrc.cjs +19 -0
  49. apps/web/.gitignore +24 -0
  50. apps/web/README.md +30 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .git
3
+ .gitignore
4
+ *.md
5
+ dist
6
+ .env
7
+ .next
8
+ .DS_Store
.github/workflows/docker-release.yml ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Build WeWeRSS images and push image to docker hub
2
+ on:
3
+ workflow_dispatch:
4
+ push:
5
+ # paths:
6
+ # - "apps/**"
7
+ # - "Dockerfile"
8
+ tags:
9
+ - "v*.*.*"
10
+
11
+ concurrency:
12
+ group: docker-release
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ check-env:
17
+ permissions:
18
+ contents: none
19
+ runs-on: ubuntu-latest
20
+ timeout-minutes: 5
21
+ outputs:
22
+ check-docker: ${{ steps.check-docker.outputs.defined }}
23
+ steps:
24
+ - id: check-docker
25
+ env:
26
+ DOCKER_HUB_NAME: ${{ secrets.DOCKER_HUB_NAME }}
27
+ if: ${{ env.DOCKER_HUB_NAME != '' }}
28
+ run: echo "defined=true" >> $GITHUB_OUTPUT
29
+
30
+ release-images:
31
+ runs-on: ubuntu-latest
32
+ timeout-minutes: 120
33
+ permissions:
34
+ packages: write
35
+ contents: read
36
+ id-token: write
37
+ steps:
38
+ - name: Checkout
39
+ uses: actions/checkout@v4
40
+ with:
41
+ fetch-depth: 1
42
+
43
+ - name: Set up QEMU
44
+ uses: docker/setup-qemu-action@v3
45
+
46
+ - name: Set up Docker Buildx
47
+ uses: docker/setup-buildx-action@v3
48
+
49
+ - name: Login to Docker Hub
50
+ uses: docker/login-action@v2
51
+ with:
52
+ username: ${{ secrets.DOCKER_HUB_NAME }}
53
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
54
+
55
+ - name: Login to GitHub Container Registry
56
+ uses: docker/login-action@v3
57
+ with:
58
+ registry: ghcr.io
59
+ username: ${{ github.repository_owner }}
60
+ password: ${{ secrets.GITHUB_TOKEN }}
61
+
62
+ - name: Extract Docker metadata (sqlite)
63
+ id: meta-sqlite
64
+ uses: docker/metadata-action@v5
65
+ with:
66
+ images: |
67
+ ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite
68
+ ghcr.io/cooderl/wewe-rss-sqlite
69
+ tags: |
70
+ type=raw,value=latest,enable=true
71
+ type=raw,value=${{ github.ref_name }},enable=true
72
+ flavor: latest=false
73
+
74
+ - name: Build and push Docker image (sqlite)
75
+ id: build-and-push-sqlite
76
+ uses: docker/build-push-action@v5
77
+ with:
78
+ context: .
79
+ push: true
80
+ tags: ${{ steps.meta-sqlite.outputs.tags }}
81
+ labels: ${{ steps.meta-sqlite.outputs.labels }}
82
+ target: app-sqlite
83
+ platforms: linux/amd64,linux/arm64
84
+ cache-from: type=gha,scope=docker-release
85
+ cache-to: type=gha,mode=max,scope=docker-release
86
+
87
+ - name: Extract Docker metadata
88
+ id: meta
89
+ uses: docker/metadata-action@v5
90
+ with:
91
+ images: |
92
+ ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss
93
+ ghcr.io/cooderl/wewe-rss
94
+ tags: |
95
+ type=raw,value=latest,enable=true
96
+ type=raw,value=${{ github.ref_name }},enable=true
97
+ flavor: latest=false
98
+
99
+ - name: Build and push Docker image
100
+ id: build-and-push
101
+ uses: docker/build-push-action@v5
102
+ with:
103
+ context: .
104
+ push: true
105
+ tags: ${{ steps.meta.outputs.tags }}
106
+ labels: ${{ steps.meta.outputs.labels }}
107
+ target: app
108
+ platforms: linux/amd64,linux/arm64
109
+ cache-from: type=gha,scope=docker-release
110
+ cache-to: type=gha,mode=max,scope=docker-release
111
+
112
+ - name: Set env
113
+ run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
114
+
115
+ - name: Create a Release
116
+ uses: elgohr/Github-Release-Action@v5
117
+ env:
118
+ GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
119
+ with:
120
+ title: ${{ env.RELEASE_VERSION }}
121
+
122
+ description:
123
+ runs-on: ubuntu-latest
124
+ needs: check-env
125
+ if: needs.check-env.outputs.check-docker == 'true'
126
+ timeout-minutes: 5
127
+ steps:
128
+ - uses: actions/checkout@v4
129
+
130
+ - name: Docker Hub Description(sqlite)
131
+ uses: peter-evans/dockerhub-description@v4
132
+ with:
133
+ username: ${{ secrets.DOCKER_HUB_NAME }}
134
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
135
+ repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite
136
+
137
+ - name: Docker Hub Description
138
+ uses: peter-evans/dockerhub-description@v4
139
+ with:
140
+ username: ${{ secrets.DOCKER_HUB_NAME }}
141
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
142
+ repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss
.gitignore ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ lerna-debug.log*
8
+ .pnpm-debug.log*
9
+
10
+ # Diagnostic reports (https://nodejs.org/api/report.html)
11
+ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12
+
13
+ # Runtime data
14
+ pids
15
+ *.pid
16
+ *.seed
17
+ *.pid.lock
18
+
19
+ # Directory for instrumented libs generated by jscoverage/JSCover
20
+ lib-cov
21
+
22
+ # Coverage directory used by tools like istanbul
23
+ coverage
24
+ *.lcov
25
+
26
+ # nyc test coverage
27
+ .nyc_output
28
+
29
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30
+ .grunt
31
+
32
+ # Bower dependency directory (https://bower.io/)
33
+ bower_components
34
+
35
+ # node-waf configuration
36
+ .lock-wscript
37
+
38
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
39
+ build/Release
40
+
41
+ # Dependency directories
42
+ node_modules/
43
+ jspm_packages/
44
+
45
+ # Snowpack dependency directory (https://snowpack.dev/)
46
+ web_modules/
47
+
48
+ # TypeScript cache
49
+ *.tsbuildinfo
50
+
51
+ # Optional npm cache directory
52
+ .npm
53
+
54
+ # Optional eslint cache
55
+ .eslintcache
56
+
57
+ # Optional stylelint cache
58
+ .stylelintcache
59
+
60
+ # Microbundle cache
61
+ .rpt2_cache/
62
+ .rts2_cache_cjs/
63
+ .rts2_cache_es/
64
+ .rts2_cache_umd/
65
+
66
+ # Optional REPL history
67
+ .node_repl_history
68
+
69
+ # Output of 'npm pack'
70
+ *.tgz
71
+
72
+ # Yarn Integrity file
73
+ .yarn-integrity
74
+
75
+ # dotenv environment variable files
76
+ .env
77
+ .env.development.local
78
+ .env.test.local
79
+ .env.production.local
80
+ .env.local
81
+
82
+ # parcel-bundler cache (https://parceljs.org/)
83
+ .cache
84
+ .parcel-cache
85
+
86
+ # Next.js build output
87
+ .next
88
+ out
89
+
90
+ # Nuxt.js build / generate output
91
+ .nuxt
92
+ dist
93
+
94
+ # Gatsby files
95
+ .cache/
96
+ # Comment in the public line in if your project uses Gatsby and not Next.js
97
+ # https://nextjs.org/blog/next-9-1#public-directory-support
98
+ # public
99
+
100
+ # vuepress build output
101
+ .vuepress/dist
102
+
103
+ # vuepress v2.x temp and cache directory
104
+ .temp
105
+ .cache
106
+
107
+ # Docusaurus cache and generated files
108
+ .docusaurus
109
+
110
+ # Serverless directories
111
+ .serverless/
112
+
113
+ # FuseBox cache
114
+ .fusebox/
115
+
116
+ # DynamoDB Local files
117
+ .dynamodb/
118
+
119
+ # TernJS port file
120
+ .tern-port
121
+
122
+ # Stores VSCode versions used for testing VSCode extensions
123
+ .vscode-test
124
+
125
+ # yarn v2
126
+ .yarn/cache
127
+ .yarn/unplugged
128
+ .yarn/build-state.yml
129
+ .yarn/install-state.gz
130
+ .pnp.*
131
+
132
+ .DS_Store
.markdownlint.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Default state for all rules
2
+ default: true
3
+
4
+ line-length: false
5
+
6
+ # MD033/no-inline-html - Inline HTML
7
+ MD033:
8
+ # Allowed elements
9
+ allowed_elements: ["style"]
.npmrc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ public-hoist-pattern[]=*@nextui-org/*
2
+ engine-strict=true
3
+ deploy-all-files=true
.prettierignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ **/*.log
2
+ **/.DS_Store
3
+ *.
4
+ *.json
5
+ apps/web/.next
6
+ dist
7
+ node_modules
8
+ pnpm-lock.yaml
.prettierrc.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "tabWidth": 2,
3
+ "singleQuote": true,
4
+ "trailingComma": "all"
5
+ }
.vscode/extensions.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "recommendations": [
3
+ "esbenp.prettier-vscode",
4
+ "dbaeumer.vscode-eslint",
5
+ "stylelint.vscode-stylelint",
6
+ "streetsidesoftware.code-spell-checker",
7
+ "DavidAnson.vscode-markdownlint",
8
+ "Gruntfuggly.todo-tree",
9
+ "mikestead.dotenv",
10
+ "foxundermoon.next-js",
11
+ "Prisma.prisma",
12
+ "planbcoding.vscode-react-refactor",
13
+ "yoavbls.pretty-ts-errors",
14
+ "usernamehw.errorlens"
15
+ ]
16
+ }
.vscode/settings.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib",
3
+ "typescript.enablePromptUseWorkspaceTsdk": true,
4
+ "[javascript]": {
5
+ "editor.formatOnSave": true,
6
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
7
+ },
8
+ "[typescript]": {
9
+ "editor.formatOnSave": true,
10
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
11
+ },
12
+ "[html]": {
13
+ "editor.formatOnSave": true,
14
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
15
+ },
16
+ "[scss]": {
17
+ "editor.formatOnSave": true,
18
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
19
+ },
20
+ "[css]": {
21
+ "editor.formatOnSave": true,
22
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
23
+ },
24
+ "[yaml]": {
25
+ "editor.formatOnSave": true,
26
+ "editor.defaultFormatter": "redhat.vscode-yaml"
27
+ },
28
+ "[json]": {
29
+ "editor.formatOnSave": true,
30
+ "editor.defaultFormatter": "vscode.json-language-features"
31
+ },
32
+ "cSpell.words": [
33
+ "callout",
34
+ "checkstyle",
35
+ "commitlint",
36
+ "daisyui",
37
+ "nestjs",
38
+ "nextui",
39
+ "tailwindcss",
40
+ "Trpc",
41
+ "wewe"
42
+ ]
43
+ }
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/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:123456@127.0.0.1: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
+ <!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
23
+ [![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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.9.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,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Controller, Get, Redirect, Render } from '@nestjs/common';
2
+ import { AppService } from './app.service';
3
+ import { ConfigService } from '@nestjs/config';
4
+ import { ConfigurationType } from './configuration';
5
+
6
+ @Controller()
7
+ export class AppController {
8
+ constructor(
9
+ private readonly appService: AppService,
10
+ private readonly configService: ConfigService,
11
+ ) {}
12
+
13
+ @Get()
14
+ getHello(): string {
15
+ return this.appService.getHello();
16
+ }
17
+
18
+ @Get('/robots.txt')
19
+ forRobot(): string {
20
+ return 'User-agent: *\nDisallow: /';
21
+ }
22
+
23
+ @Get('favicon.ico')
24
+ @Redirect('https://r2-assets.111965.xyz/wewe-rss.png', 302)
25
+ getFavicon() {}
26
+
27
+ @Get('/dash*')
28
+ @Render('index.hbs')
29
+ dashRender() {
30
+ const { originUrl: weweRssServerOriginUrl } =
31
+ this.configService.get<ConfigurationType['feed']>('feed')!;
32
+ const { code } = this.configService.get<ConfigurationType['auth']>('auth')!;
33
+
34
+ return {
35
+ weweRssServerOriginUrl,
36
+ enabledAuthCode: !!code,
37
+ };
38
+ }
39
+ }
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,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+
4
+ @Injectable()
5
+ export class AppService {
6
+ constructor(private readonly configService: ConfigService) {}
7
+ getHello(): string {
8
+ return `
9
+ <div style="display:flex;justify-content: center;height: 100%;align-items: center;font-size: 30px;">
10
+ <div>>> <a href="/dash">WeWe RSS</a> <<</div>
11
+ </div>
12
+ `;
13
+ }
14
+ }
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,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ timeout: 8 * 1e3,
38
+ headers: {
39
+ accept:
40
+ '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',
41
+ 'accept-encoding': 'gzip, deflate, br',
42
+ 'accept-language': 'en-US,en;q=0.9',
43
+ 'cache-control': 'max-age=0',
44
+ 'sec-ch-ua':
45
+ '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"',
46
+ 'sec-ch-ua-mobile': '?0',
47
+ 'sec-ch-ua-platform': '"macOS"',
48
+ 'sec-fetch-dest': 'document',
49
+ 'sec-fetch-mode': 'navigate',
50
+ 'sec-fetch-site': 'none',
51
+ 'sec-fetch-user': '?1',
52
+ 'upgrade-insecure-requests': '1',
53
+ 'user-agent':
54
+ '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',
55
+ },
56
+ hooks: {
57
+ beforeRetry: [
58
+ async (options, error, retryCount) => {
59
+ this.logger.warn(`retrying ${options.url}...`);
60
+ return new Promise((resolve) =>
61
+ setTimeout(resolve, 2e3 * (retryCount || 1)),
62
+ );
63
+ },
64
+ ],
65
+ },
66
+ });
67
+ }
68
+
69
+ @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', {
70
+ name: 'updateFeeds',
71
+ timeZone: 'Asia/Shanghai',
72
+ })
73
+ async handleUpdateFeedsCron() {
74
+ this.logger.debug('Called handleUpdateFeedsCron');
75
+
76
+ const feeds = await this.prismaService.feed.findMany({
77
+ where: { status: 1 },
78
+ });
79
+ this.logger.debug('feeds length:' + feeds.length);
80
+
81
+ for (const feed of feeds) {
82
+ this.logger.debug('feed', feed.id);
83
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);
84
+ // wait 30s for next feed
85
+ await new Promise((resolve) => setTimeout(resolve, 90 * 1e3));
86
+ }
87
+ }
88
+
89
+ async cleanHtml(source: string) {
90
+ const $ = load(source, { decodeEntities: false });
91
+
92
+ const dirtyHtml = $.html($('.rich_media_content'));
93
+
94
+ const html = dirtyHtml
95
+ .replace(/data-src=/g, 'src=')
96
+ .replace(/visibility: hidden;/g, '');
97
+
98
+ const content =
99
+ '<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>' +
100
+ html;
101
+
102
+ const result = minify(content, {
103
+ removeAttributeQuotes: true,
104
+ collapseWhitespace: true,
105
+ });
106
+
107
+ return result;
108
+ }
109
+
110
+ async getHtmlByUrl(url: string) {
111
+ const html = await this.request(url, { responseType: 'text' }).text();
112
+ const result = await this.cleanHtml(html);
113
+
114
+ return result;
115
+ }
116
+
117
+ async tryGetContent(id: string) {
118
+ let content = mpCache.get(id) as string;
119
+ if (content) {
120
+ return content;
121
+ }
122
+ const url = `https://mp.weixin.qq.com/s/${id}`;
123
+ content = await this.getHtmlByUrl(url).catch((e) => {
124
+ this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`);
125
+
126
+ return '获取全文失败,请重试~';
127
+ });
128
+ mpCache.set(id, content);
129
+ return content;
130
+ }
131
+
132
+ async renderFeed({
133
+ type,
134
+ feedInfo,
135
+ articles,
136
+ mode,
137
+ }: {
138
+ type: string;
139
+ feedInfo: FeedInfo;
140
+ articles: Article[];
141
+ mode?: string;
142
+ }) {
143
+ const { originUrl, mode: globalMode } =
144
+ this.configService.get<ConfigurationType['feed']>('feed')!;
145
+
146
+ const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;
147
+
148
+ const feed = new Feed({
149
+ title: feedInfo.mpName,
150
+ description: feedInfo.mpIntro,
151
+ id: link,
152
+ link: link,
153
+ language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
154
+ image: feedInfo.mpCover,
155
+ favicon: feedInfo.mpCover,
156
+ copyright: '',
157
+ updated: new Date(feedInfo.updateTime * 1e3),
158
+ generator: 'WeWe-RSS',
159
+ author: { name: feedInfo.mpName },
160
+ });
161
+
162
+ feed.addExtension({
163
+ name: 'generator',
164
+ objects: `WeWe-RSS`,
165
+ });
166
+
167
+ const feeds = await this.prismaService.feed.findMany({
168
+ select: { id: true, mpName: true },
169
+ });
170
+
171
+ /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/
172
+ const enableFullText =
173
+ typeof mode === 'string'
174
+ ? mode === 'fulltext'
175
+ : globalMode === 'fulltext';
176
+
177
+ const showAuthor = feedInfo.id === 'all';
178
+
179
+ const mapper = async (item) => {
180
+ const { title, id, publishTime, picUrl, mpId } = item;
181
+ const link = `https://mp.weixin.qq.com/s/${id}`;
182
+
183
+ const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-';
184
+ const published = new Date(publishTime * 1e3);
185
+
186
+ let description = '';
187
+ if (enableFullText) {
188
+ description = await this.tryGetContent(id);
189
+ }
190
+
191
+ feed.addItem({
192
+ id,
193
+ title,
194
+ link: link,
195
+ guid: link,
196
+ description,
197
+ date: published,
198
+ image: picUrl,
199
+ author: showAuthor ? [{ name: mpName }] : undefined,
200
+ });
201
+ };
202
+
203
+ await pMap(articles, mapper, { concurrency: 2, stopOnError: false });
204
+
205
+ return feed;
206
+ }
207
+
208
+ async handleGenerateFeed({
209
+ id,
210
+ type,
211
+ limit,
212
+ mode,
213
+ }: {
214
+ id?: string;
215
+ type: string;
216
+ limit: number;
217
+ mode?: string;
218
+ }) {
219
+ if (!feedTypes.includes(type as any)) {
220
+ type = 'atom';
221
+ }
222
+
223
+ let articles: Article[];
224
+ let feedInfo: FeedInfo;
225
+ if (id) {
226
+ feedInfo = (await this.prismaService.feed.findFirst({
227
+ where: { id },
228
+ }))!;
229
+
230
+ if (!feedInfo) {
231
+ throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST);
232
+ }
233
+
234
+ articles = await this.prismaService.article.findMany({
235
+ where: { mpId: id },
236
+ orderBy: { publishTime: 'desc' },
237
+ take: limit,
238
+ });
239
+ } else {
240
+ articles = await this.prismaService.article.findMany({
241
+ orderBy: { publishTime: 'desc' },
242
+ take: limit,
243
+ });
244
+
245
+ feedInfo = {
246
+ id: 'all',
247
+ mpName: 'WeWe-RSS All',
248
+ mpIntro: 'WeWe-RSS 全部文章',
249
+ mpCover: 'https://r2-assets.111965.xyz/wewe-rss.png',
250
+ status: 1,
251
+ syncTime: 0,
252
+ updateTime: Math.floor(Date.now() / 1e3),
253
+ createdAt: new Date(),
254
+ updatedAt: new Date(),
255
+ };
256
+ }
257
+
258
+ this.logger.log('handleGenerateFeed articles: ' + articles.length);
259
+ const feed = await this.renderFeed({ feedInfo, articles, type, mode });
260
+
261
+ switch (type) {
262
+ case 'rss':
263
+ return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };
264
+ case 'json':
265
+ return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };
266
+ case 'atom':
267
+ default:
268
+ return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };
269
+ }
270
+ }
271
+
272
+ async getFeedList() {
273
+ const data = await this.prismaService.feed.findMany();
274
+
275
+ return data.map((item) => {
276
+ return {
277
+ id: item.id,
278
+ name: item.mpName,
279
+ intro: item.mpIntro,
280
+ cover: item.mpCover,
281
+ syncTime: item.syncTime,
282
+ updateTime: item.updateTime,
283
+ };
284
+ });
285
+ }
286
+ }
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(500).nullish(),
26
+ cursor: z.string().nullish(),
27
+ }),
28
+ )
29
+ .query(async ({ input }) => {
30
+ const limit = input.limit ?? 500;
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(500).nullish(),
136
+ cursor: z.string().nullish(),
137
+ }),
138
+ )
139
+ .query(async ({ input }) => {
140
+ const limit = input.limit ?? 500;
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(500).nullish(),
251
+ cursor: z.string().nullish(),
252
+ mpId: z.string().nullish(),
253
+ }),
254
+ )
255
+ .query(async ({ input }) => {
256
+ const limit = input.limit ?? 500;
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 (authCode && 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,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ url = url.trim();
189
+ const account = await this.getAvailableAccount();
190
+
191
+ return this.request
192
+ .post<
193
+ {
194
+ id: string;
195
+ cover: string;
196
+ name: string;
197
+ intro: string;
198
+ updateTime: number;
199
+ }[]
200
+ >(
201
+ `/api/platform/wxs2mp`,
202
+ { url },
203
+ {
204
+ headers: {
205
+ xid: account.id,
206
+ Authorization: `Bearer ${account.token}`,
207
+ },
208
+ },
209
+ )
210
+ .then((res) => res.data);
211
+ }
212
+
213
+ async createLoginUrl() {
214
+ return this.request
215
+ .post<{
216
+ uuid: string;
217
+ scanUrl: string;
218
+ }>(`/api/login/platform`)
219
+ .then((res) => res.data);
220
+ }
221
+
222
+ async getLoginResult(id: string) {
223
+ return this.request
224
+ .get<{
225
+ message: 'waiting' | 'success';
226
+ vid?: number;
227
+ token?: string;
228
+ username?: string;
229
+ }>(`/api/login/platform/${id}`)
230
+ .then((res) => res.data);
231
+ }
232
+ }
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