diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7f1def4804880914cce592dab348df618ed35f6d
--- /dev/null
+++ b/.github/workflows/docker-release.yml
@@ -0,0 +1,142 @@
+name: Build WeWeRSS images and push image to docker hub
+on:
+ workflow_dispatch:
+ push:
+ # paths:
+ # - "apps/**"
+ # - "Dockerfile"
+ tags:
+ - "v*.*.*"
+
+concurrency:
+ group: docker-release
+ cancel-in-progress: true
+
+jobs:
+ check-env:
+ permissions:
+ contents: none
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ check-docker: ${{ steps.check-docker.outputs.defined }}
+ steps:
+ - id: check-docker
+ env:
+ DOCKER_HUB_NAME: ${{ secrets.DOCKER_HUB_NAME }}
+ if: ${{ env.DOCKER_HUB_NAME != '' }}
+ run: echo "defined=true" >> $GITHUB_OUTPUT
+
+ release-images:
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ permissions:
+ packages: write
+ contents: read
+ id-token: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKER_HUB_NAME }}
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract Docker metadata (sqlite)
+ id: meta-sqlite
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite
+ ghcr.io/cooderl/wewe-rss-sqlite
+ tags: |
+ type=raw,value=latest,enable=true
+ type=raw,value=${{ github.ref_name }},enable=true
+ flavor: latest=false
+
+ - name: Build and push Docker image (sqlite)
+ id: build-and-push-sqlite
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta-sqlite.outputs.tags }}
+ labels: ${{ steps.meta-sqlite.outputs.labels }}
+ target: app-sqlite
+ platforms: linux/amd64,linux/arm64
+ cache-from: type=gha,scope=docker-release
+ cache-to: type=gha,mode=max,scope=docker-release
+
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss
+ ghcr.io/cooderl/wewe-rss
+ tags: |
+ type=raw,value=latest,enable=true
+ type=raw,value=${{ github.ref_name }},enable=true
+ flavor: latest=false
+
+ - name: Build and push Docker image
+ id: build-and-push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ target: app
+ platforms: linux/amd64,linux/arm64
+ cache-from: type=gha,scope=docker-release
+ cache-to: type=gha,mode=max,scope=docker-release
+
+ - name: Set env
+ run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
+
+ - name: Create a Release
+ uses: elgohr/Github-Release-Action@v5
+ env:
+ GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ with:
+ title: ${{ env.RELEASE_VERSION }}
+
+ description:
+ runs-on: ubuntu-latest
+ needs: check-env
+ if: needs.check-env.outputs.check-docker == 'true'
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Docker Hub Description(sqlite)
+ uses: peter-evans/dockerhub-description@v4
+ with:
+ username: ${{ secrets.DOCKER_HUB_NAME }}
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
+ repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss-sqlite
+
+ - name: Docker Hub Description
+ uses: peter-evans/dockerhub-description@v4
+ with:
+ username: ${{ secrets.DOCKER_HUB_NAME }}
+ password: ${{ secrets.DOCKER_HUB_PASSWORD }}
+ repository: ${{ secrets.DOCKER_HUB_NAME }}/wewe-rss
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000000000000000000000000000000000000..dd0065d599724787e53c4b4b2e24cb584dc428d7
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,16 @@
+{
+ "recommendations": [
+ "esbenp.prettier-vscode",
+ "dbaeumer.vscode-eslint",
+ "stylelint.vscode-stylelint",
+ "streetsidesoftware.code-spell-checker",
+ "DavidAnson.vscode-markdownlint",
+ "Gruntfuggly.todo-tree",
+ "mikestead.dotenv",
+ "foxundermoon.next-js",
+ "Prisma.prisma",
+ "planbcoding.vscode-react-refactor",
+ "yoavbls.pretty-ts-errors",
+ "usernamehw.errorlens"
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..a0f8d4344fc9f1b14a52b64880151bd552e5d645
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,43 @@
+{
+ "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib",
+ "typescript.enablePromptUseWorkspaceTsdk": true,
+ "[javascript]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescript]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[html]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[scss]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[css]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[yaml]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "redhat.vscode-yaml"
+ },
+ "[json]": {
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "vscode.json-language-features"
+ },
+ "cSpell.words": [
+ "callout",
+ "checkstyle",
+ "commitlint",
+ "daisyui",
+ "nestjs",
+ "nextui",
+ "tailwindcss",
+ "Trpc",
+ "wewe"
+ ]
+}
\ No newline at end of file
diff --git a/apps/server/.env.local.example b/apps/server/.env.local.example
new file mode 100644
index 0000000000000000000000000000000000000000..9b7dfea8c6ab3e025f4628784ca9107feb9ef917
--- /dev/null
+++ b/apps/server/.env.local.example
@@ -0,0 +1,28 @@
+HOST=0.0.0.0
+PORT=4000
+
+# Prisma
+# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
+DATABASE_URL="mysql://root:123456@127.0.0.1:3306/wewe-rss"
+
+# 使用Sqlite
+# DATABASE_URL="file:../data/wewe-rss.db"
+# DATABASE_TYPE="sqlite"
+
+# 访问授权码
+AUTH_CODE=123567
+
+# 每分钟最大请求次数
+MAX_REQUEST_PER_MINUTE=60
+
+# 自动提取全文内容
+FEED_MODE="fulltext"
+
+# nginx 转发后的服务端地址
+SERVER_ORIGIN_URL=http://localhost:4000
+
+# 定时更新订阅源Cron表达式
+CRON_EXPRESSION="35 5,17 * * *"
+
+# 读书转发服务,不需要修改
+PLATFORM_URL="https://weread.111965.xyz"
\ No newline at end of file
diff --git a/apps/server/.eslintrc.js b/apps/server/.eslintrc.js
new file mode 100644
index 0000000000000000000000000000000000000000..259de13c733a7284a352a5cba1e9fd57e97e431d
--- /dev/null
+++ b/apps/server/.eslintrc.js
@@ -0,0 +1,25 @@
+module.exports = {
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: 'tsconfig.json',
+ tsconfigRootDir: __dirname,
+ sourceType: 'module',
+ },
+ plugins: ['@typescript-eslint/eslint-plugin'],
+ extends: [
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:prettier/recommended',
+ ],
+ root: true,
+ env: {
+ node: true,
+ jest: true,
+ },
+ ignorePatterns: ['.eslintrc.js'],
+ rules: {
+ '@typescript-eslint/interface-name-prefix': 'off',
+ '@typescript-eslint/explicit-function-return-type': 'off',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+};
diff --git a/apps/server/.gitignore b/apps/server/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..271305e69cbee07cf6b5ed562019dc3a9d090b97
--- /dev/null
+++ b/apps/server/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+# Keep environment variables out of version control
+.env
+
+client
+data
\ No newline at end of file
diff --git a/apps/server/.prettierrc.json b/apps/server/.prettierrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..aff308a815c099dd789001e218a0c3b8ddff994b
--- /dev/null
+++ b/apps/server/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "tabWidth": 2,
+ "singleQuote": true,
+ "trailingComma": "all"
+}
\ No newline at end of file
diff --git a/apps/server/README.md b/apps/server/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f5aa86c5dc479cee604999f8faa2451b99a56c2b
--- /dev/null
+++ b/apps/server/README.md
@@ -0,0 +1,73 @@
+
+
+
+
+[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
+[circleci-url]: https://circleci.com/gh/nestjs/nest
+
+ A progressive Node.js framework for building efficient and scalable server-side applications.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Description
+
+[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
+
+## Installation
+
+```bash
+$ pnpm install
+```
+
+## Running the app
+
+```bash
+# development
+$ pnpm run start
+
+# watch mode
+$ pnpm run start:dev
+
+# production mode
+$ pnpm run start:prod
+```
+
+## Test
+
+```bash
+# unit tests
+$ pnpm run test
+
+# e2e tests
+$ pnpm run test:e2e
+
+# test coverage
+$ pnpm run test:cov
+```
+
+## Support
+
+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).
+
+## Stay in touch
+
+- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
+- Website - [https://nestjs.com](https://nestjs.com/)
+- Twitter - [@nestframework](https://twitter.com/nestframework)
+
+## License
+
+Nest is [MIT licensed](LICENSE).
diff --git a/apps/server/docker-bootstrap.sh b/apps/server/docker-bootstrap.sh
new file mode 100644
index 0000000000000000000000000000000000000000..9f430454e8d82dcea3ee5e1a5709955fbd1d824c
--- /dev/null
+++ b/apps/server/docker-bootstrap.sh
@@ -0,0 +1,8 @@
+
+#!/bin/sh
+# ENVIRONEMTN from docker-compose.yaml doesn't get through to subprocesses
+# Need to explicit pass DATABASE_URL here, otherwise migration doesn't work
+# Run migrations
+DATABASE_URL=${DATABASE_URL} npx prisma migrate deploy
+# start app
+DATABASE_URL=${DATABASE_URL} node dist/main
\ No newline at end of file
diff --git a/apps/server/nest-cli.json b/apps/server/nest-cli.json
new file mode 100644
index 0000000000000000000000000000000000000000..f9aa683b1ad5cffc76da9ad4b77c562ac4c2b399
--- /dev/null
+++ b/apps/server/nest-cli.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json.schemastore.org/nest-cli",
+ "collection": "@nestjs/schematics",
+ "sourceRoot": "src",
+ "compilerOptions": {
+ "deleteOutDir": true
+ }
+}
diff --git a/apps/server/package.json b/apps/server/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..fff7db5d7023279a08cc5442275ce76b3ad6ff15
--- /dev/null
+++ b/apps/server/package.json
@@ -0,0 +1,93 @@
+{
+ "name": "server",
+ "version": "2.2.3",
+ "description": "",
+ "author": "",
+ "private": true,
+ "license": "UNLICENSED",
+ "scripts": {
+ "build": "nest build",
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
+ "start": "nest start",
+ "dev": "nest start --watch",
+ "start:debug": "nest start --debug --watch",
+ "start:prod": "node dist/main",
+ "start:migrate:prod": "prisma migrate deploy && npm run start:prod",
+ "postinstall": "npx prisma generate",
+ "migrate": "pnpm prisma migrate dev",
+ "studio": "pnpm prisma studio",
+ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:cov": "jest --coverage",
+ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
+ "test:e2e": "jest --config ./test/jest-e2e.json"
+ },
+ "dependencies": {
+ "@cjs-exporter/p-map": "^5.5.0",
+ "@nestjs/common": "^10.3.3",
+ "@nestjs/config": "^3.2.0",
+ "@nestjs/core": "^10.3.3",
+ "@nestjs/platform-express": "^10.3.3",
+ "@nestjs/schedule": "^4.0.1",
+ "@nestjs/throttler": "^5.1.2",
+ "@prisma/client": "5.10.1",
+ "@trpc/server": "^10.45.1",
+ "axios": "^1.6.7",
+ "cheerio": "1.0.0-rc.12",
+ "class-transformer": "^0.5.1",
+ "class-validator": "^0.14.1",
+ "dayjs": "^1.11.10",
+ "express": "^4.18.2",
+ "feed": "^4.2.2",
+ "got": "11.8.6",
+ "hbs": "^4.2.0",
+ "html-minifier": "^4.0.0",
+ "node-cache": "^5.1.2",
+ "prisma": "^5.10.2",
+ "reflect-metadata": "^0.2.1",
+ "rxjs": "^7.8.1",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@nestjs/cli": "^10.3.2",
+ "@nestjs/schematics": "^10.1.1",
+ "@nestjs/testing": "^10.3.3",
+ "@types/express": "^4.17.21",
+ "@types/html-minifier": "^4.0.5",
+ "@types/jest": "^29.5.12",
+ "@types/node": "^20.11.19",
+ "@types/supertest": "^6.0.2",
+ "@typescript-eslint/eslint-plugin": "^7.0.2",
+ "@typescript-eslint/parser": "^7.0.2",
+ "eslint": "^8.56.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-prettier": "^5.1.3",
+ "jest": "^29.7.0",
+ "prettier": "^3.2.5",
+ "source-map-support": "^0.5.21",
+ "supertest": "^6.3.4",
+ "ts-jest": "^29.1.2",
+ "ts-loader": "^9.5.1",
+ "ts-node": "^10.9.2",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "^5.3.3"
+ },
+ "jest": {
+ "moduleFileExtensions": [
+ "js",
+ "json",
+ "ts"
+ ],
+ "rootDir": "src",
+ "testRegex": ".*\\.spec\\.ts$",
+ "transform": {
+ "^.+\\.(t|j)s$": "ts-jest"
+ },
+ "collectCoverageFrom": [
+ "**/*.(t|j)s"
+ ],
+ "coverageDirectory": "../coverage",
+ "testEnvironment": "node"
+ }
+}
\ No newline at end of file
diff --git a/apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql b/apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..6d253a84314d5613cec21b24aed64f0a20ef1baf
--- /dev/null
+++ b/apps/server/prisma-sqlite/migrations/20240301104100_init/migration.sql
@@ -0,0 +1,33 @@
+-- CreateTable
+CREATE TABLE "accounts" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "token" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "status" INTEGER NOT NULL DEFAULT 1,
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "feeds" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "mp_name" TEXT NOT NULL,
+ "mp_cover" TEXT NOT NULL,
+ "mp_intro" TEXT NOT NULL,
+ "status" INTEGER NOT NULL DEFAULT 1,
+ "sync_time" INTEGER NOT NULL DEFAULT 0,
+ "update_time" INTEGER NOT NULL,
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "articles" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "mp_id" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "pic_url" TEXT NOT NULL,
+ "publish_time" INTEGER NOT NULL,
+ "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
+);
diff --git a/apps/server/prisma-sqlite/migrations/migration_lock.toml b/apps/server/prisma-sqlite/migrations/migration_lock.toml
new file mode 100644
index 0000000000000000000000000000000000000000..e5e5c4705ab084270b7de6f45d5291ba01666948
--- /dev/null
+++ b/apps/server/prisma-sqlite/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "sqlite"
\ No newline at end of file
diff --git a/apps/server/prisma-sqlite/schema.prisma b/apps/server/prisma-sqlite/schema.prisma
new file mode 100644
index 0000000000000000000000000000000000000000..1edde019933a16c4018faf53fb8eb9c8a2ed1831
--- /dev/null
+++ b/apps/server/prisma-sqlite/schema.prisma
@@ -0,0 +1,56 @@
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+generator client {
+ provider = "prisma-client-js"
+ binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
+}
+
+// 读书账号
+model Account {
+ id String @id
+ token String @map("token")
+ name String @map("name")
+ // 状态 0:失效 1:启用 2:禁用
+ status Int @default(1) @map("status")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
+
+ @@map("accounts")
+}
+
+// 订阅源
+model Feed {
+ id String @id
+ mpName String @map("mp_name")
+ mpCover String @map("mp_cover")
+ mpIntro String @map("mp_intro")
+ // 状态 0:失效 1:启用 2:禁用
+ status Int @default(1) @map("status")
+
+ // article最后同步时间
+ syncTime Int @default(0) @map("sync_time")
+
+ // 信息更新时间
+ updateTime Int @map("update_time")
+
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
+
+ @@map("feeds")
+}
+
+model Article {
+ id String @id
+ mpId String @map("mp_id")
+ title String @map("title")
+ picUrl String @map("pic_url")
+ publishTime Int @map("publish_time")
+
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
+
+ @@map("articles")
+}
diff --git a/apps/server/prisma/migrations/20240227153512_init/migration.sql b/apps/server/prisma/migrations/20240227153512_init/migration.sql
new file mode 100644
index 0000000000000000000000000000000000000000..c655a12a9db24c44a8f7b742dfde42278c32451c
--- /dev/null
+++ b/apps/server/prisma/migrations/20240227153512_init/migration.sql
@@ -0,0 +1,39 @@
+-- CreateTable
+CREATE TABLE `accounts` (
+ `id` VARCHAR(255) NOT NULL,
+ `token` VARCHAR(2048) NOT NULL,
+ `name` VARCHAR(1024) NOT NULL,
+ `status` INTEGER NOT NULL DEFAULT 1,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `feeds` (
+ `id` VARCHAR(255) NOT NULL,
+ `mp_name` VARCHAR(512) NOT NULL,
+ `mp_cover` VARCHAR(1024) NOT NULL,
+ `mp_intro` TEXT NOT NULL,
+ `status` INTEGER NOT NULL DEFAULT 1,
+ `sync_time` INTEGER NOT NULL DEFAULT 0,
+ `update_time` INTEGER NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `articles` (
+ `id` VARCHAR(255) NOT NULL,
+ `mp_id` VARCHAR(255) NOT NULL,
+ `title` VARCHAR(255) NOT NULL,
+ `pic_url` VARCHAR(255) NOT NULL,
+ `publish_time` INTEGER NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
diff --git a/apps/server/prisma/migrations/migration_lock.toml b/apps/server/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000000000000000000000000000000000000..e5a788a7af8fecc0478ef418b8e95c2cf2bbffdf
--- /dev/null
+++ b/apps/server/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "mysql"
\ No newline at end of file
diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma
new file mode 100644
index 0000000000000000000000000000000000000000..f541e4049f6a271ca6a5c5033265c81ae89f89ea
--- /dev/null
+++ b/apps/server/prisma/schema.prisma
@@ -0,0 +1,56 @@
+datasource db {
+ provider = "mysql"
+ url = env("DATABASE_URL")
+}
+
+generator client {
+ provider = "prisma-client-js"
+ binaryTargets = ["native", "linux-musl"] // 生成linux可执行文件
+}
+
+// 读书账号
+model Account {
+ id String @id @db.VarChar(255)
+ token String @map("token") @db.VarChar(2048)
+ name String @map("name") @db.VarChar(1024)
+ // 状态 0:失效 1:启用 2:禁用
+ status Int @default(1) @map("status") @db.Int()
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
+
+ @@map("accounts")
+}
+
+// 订阅源
+model Feed {
+ id String @id @db.VarChar(255)
+ mpName String @map("mp_name") @db.VarChar(512)
+ mpCover String @map("mp_cover") @db.VarChar(1024)
+ mpIntro String @map("mp_intro") @db.Text()
+ // 状态 0:失效 1:启用 2:禁用
+ status Int @default(1) @map("status") @db.Int()
+
+ // article最后同步时间
+ syncTime Int @default(0) @map("sync_time")
+
+ // 信息更新时间
+ updateTime Int @map("update_time")
+
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
+
+ @@map("feeds")
+}
+
+model Article {
+ id String @id @db.VarChar(255)
+ mpId String @map("mp_id") @db.VarChar(255)
+ title String @map("title") @db.VarChar(255)
+ picUrl String @map("pic_url") @db.VarChar(255)
+ publishTime Int @map("publish_time")
+
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime? @default(now()) @updatedAt @map("updated_at")
+
+ @@map("articles")
+}
diff --git a/apps/server/src/app.controller.spec.ts b/apps/server/src/app.controller.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d22f3890a380cea30641cfecc329b5c794ef5fb2
--- /dev/null
+++ b/apps/server/src/app.controller.spec.ts
@@ -0,0 +1,22 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { AppController } from './app.controller';
+import { AppService } from './app.service';
+
+describe('AppController', () => {
+ let appController: AppController;
+
+ beforeEach(async () => {
+ const app: TestingModule = await Test.createTestingModule({
+ controllers: [AppController],
+ providers: [AppService],
+ }).compile();
+
+ appController = app.get(AppController);
+ });
+
+ describe('root', () => {
+ it('should return "Hello World!"', () => {
+ expect(appController.getHello()).toBe('Hello World!');
+ });
+ });
+});
diff --git a/apps/server/src/app.controller.ts b/apps/server/src/app.controller.ts
new file mode 100644
index 0000000000000000000000000000000000000000..af8d54e95ad89b4f9aba518b9b654f7dcf28bfa7
--- /dev/null
+++ b/apps/server/src/app.controller.ts
@@ -0,0 +1,39 @@
+import { Controller, Get, Redirect, Render } from '@nestjs/common';
+import { AppService } from './app.service';
+import { ConfigService } from '@nestjs/config';
+import { ConfigurationType } from './configuration';
+
+@Controller()
+export class AppController {
+ constructor(
+ private readonly appService: AppService,
+ private readonly configService: ConfigService,
+ ) {}
+
+ @Get()
+ getHello(): string {
+ return this.appService.getHello();
+ }
+
+ @Get('/robots.txt')
+ forRobot(): string {
+ return 'User-agent: *\nDisallow: /';
+ }
+
+ @Get('favicon.ico')
+ @Redirect('https://r2-assets.111965.xyz/wewe-rss.png', 302)
+ getFavicon() {}
+
+ @Get('/dash*')
+ @Render('index.hbs')
+ dashRender() {
+ const { originUrl: weweRssServerOriginUrl } =
+ this.configService.get('feed')!;
+ const { code } = this.configService.get('auth')!;
+
+ return {
+ weweRssServerOriginUrl,
+ enabledAuthCode: !!code,
+ };
+ }
+}
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c18c83e21f3ed0e4be21bbfcce7f5e1c6b64019d
--- /dev/null
+++ b/apps/server/src/app.module.ts
@@ -0,0 +1,39 @@
+import { Module } from '@nestjs/common';
+import { AppController } from './app.controller';
+import { AppService } from './app.service';
+import { TrpcModule } from '@server/trpc/trpc.module';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import configuration, { ConfigurationType } from './configuration';
+import { ThrottlerModule } from '@nestjs/throttler';
+import { ScheduleModule } from '@nestjs/schedule';
+import { FeedsModule } from './feeds/feeds.module';
+
+@Module({
+ imports: [
+ TrpcModule,
+ FeedsModule,
+ ScheduleModule.forRoot(),
+ ConfigModule.forRoot({
+ isGlobal: true,
+ envFilePath: ['.env.local', '.env'],
+ load: [configuration],
+ }),
+ ThrottlerModule.forRootAsync({
+ imports: [ConfigModule],
+ inject: [ConfigService],
+ useFactory(config: ConfigService) {
+ const throttler =
+ config.get('throttler');
+ return [
+ {
+ ttl: 60,
+ limit: throttler?.maxRequestPerMinute || 60,
+ },
+ ];
+ },
+ }),
+ ],
+ controllers: [AppController],
+ providers: [AppService],
+})
+export class AppModule {}
diff --git a/apps/server/src/app.service.ts b/apps/server/src/app.service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2530ce6ab30495cf95ec254af8afb736fa1e6ce4
--- /dev/null
+++ b/apps/server/src/app.service.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class AppService {
+ constructor(private readonly configService: ConfigService) {}
+ getHello(): string {
+ return `
+
+ `;
+ }
+}
diff --git a/apps/server/src/configuration.ts b/apps/server/src/configuration.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fba14d595f8cfe64555668c470ba62e9bdf86f3a
--- /dev/null
+++ b/apps/server/src/configuration.ts
@@ -0,0 +1,35 @@
+const configuration = () => {
+ const isProd = process.env.NODE_ENV === 'production';
+ const port = process.env.PORT || 4000;
+ const host = process.env.HOST || '0.0.0.0';
+
+ const maxRequestPerMinute = parseInt(
+ `${process.env.MAX_REQUEST_PER_MINUTE}|| 60`,
+ );
+
+ const authCode = process.env.AUTH_CODE;
+ const platformUrl = process.env.PLATFORM_URL || 'https://weread.111965.xyz';
+ const originUrl = process.env.SERVER_ORIGIN_URL || '';
+
+ const feedMode = process.env.FEED_MODE as 'fulltext' | '';
+
+ const databaseType = process.env.DATABASE_TYPE || 'mysql';
+
+ return {
+ server: { isProd, port, host },
+ throttler: { maxRequestPerMinute },
+ auth: { code: authCode },
+ platform: { url: platformUrl },
+ feed: {
+ originUrl,
+ mode: feedMode,
+ },
+ database: {
+ type: databaseType,
+ },
+ };
+};
+
+export default configuration;
+
+export type ConfigurationType = ReturnType;
diff --git a/apps/server/src/constants.ts b/apps/server/src/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..476ca193f52e98f62fc75876df094cfb8767229a
--- /dev/null
+++ b/apps/server/src/constants.ts
@@ -0,0 +1,14 @@
+export const statusMap = {
+ // 0:失效 1:启用 2:禁用
+ INVALID: 0,
+ ENABLE: 1,
+ DISABLE: 2,
+};
+
+export const feedTypes = ['rss', 'atom', 'json'] as const;
+
+export const feedMimeTypeMap = {
+ rss: 'application/rss+xml; charset=utf-8',
+ atom: 'application/atom+xml; charset=utf-8',
+ json: 'application/feed+json; charset=utf-8',
+} as const;
diff --git a/apps/server/src/feeds/feeds.controller.spec.ts b/apps/server/src/feeds/feeds.controller.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..05646556c0b1d7f34d449b841349ff40e8e28965
--- /dev/null
+++ b/apps/server/src/feeds/feeds.controller.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { FeedsController } from './feeds.controller';
+
+describe('FeedsController', () => {
+ let controller: FeedsController;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [FeedsController],
+ }).compile();
+
+ controller = module.get(FeedsController);
+ });
+
+ it('should be defined', () => {
+ expect(controller).toBeDefined();
+ });
+});
diff --git a/apps/server/src/feeds/feeds.controller.ts b/apps/server/src/feeds/feeds.controller.ts
new file mode 100644
index 0000000000000000000000000000000000000000..edc3eea51a7992dc2db6d79a646e1b894beef40f
--- /dev/null
+++ b/apps/server/src/feeds/feeds.controller.ts
@@ -0,0 +1,64 @@
+import {
+ Controller,
+ DefaultValuePipe,
+ Get,
+ Logger,
+ Param,
+ ParseIntPipe,
+ Query,
+ Request,
+ Response,
+} from '@nestjs/common';
+import { FeedsService } from './feeds.service';
+import { Response as Res, Request as Req } from 'express';
+
+@Controller('feeds')
+export class FeedsController {
+ private readonly logger = new Logger(this.constructor.name);
+
+ constructor(private readonly feedsService: FeedsService) {}
+
+ @Get('/')
+ async getFeedList() {
+ return this.feedsService.getFeedList();
+ }
+
+ @Get('/all.(json|rss|atom)')
+ async getFeeds(
+ @Request() req: Req,
+ @Response() res: Res,
+ @Query('limit', new DefaultValuePipe(30), ParseIntPipe) limit: number = 30,
+ @Query('mode') mode: string,
+ ) {
+ const path = req.path;
+ const type = path.split('.').pop() || '';
+ const { content, mimeType } = await this.feedsService.handleGenerateFeed({
+ type,
+ limit,
+ mode,
+ });
+
+ res.setHeader('Content-Type', mimeType);
+ res.send(content);
+ }
+
+ @Get('/:feed')
+ async getFeed(
+ @Response() res: Res,
+ @Param('feed') feed: string,
+ @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number = 10,
+ @Query('mode') mode: string,
+ ) {
+ const [id, type] = feed.split('.');
+ this.logger.log('getFeed: ', id);
+ const { content, mimeType } = await this.feedsService.handleGenerateFeed({
+ id,
+ type,
+ limit,
+ mode,
+ });
+
+ res.setHeader('Content-Type', mimeType);
+ res.send(content);
+ }
+}
diff --git a/apps/server/src/feeds/feeds.module.ts b/apps/server/src/feeds/feeds.module.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8b8342d70a55983ea8f92345ff615878677b008e
--- /dev/null
+++ b/apps/server/src/feeds/feeds.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { FeedsController } from './feeds.controller';
+import { FeedsService } from './feeds.service';
+import { PrismaModule } from '@server/prisma/prisma.module';
+import { TrpcModule } from '@server/trpc/trpc.module';
+
+@Module({
+ imports: [PrismaModule, TrpcModule],
+ controllers: [FeedsController],
+ providers: [FeedsService],
+})
+export class FeedsModule {}
diff --git a/apps/server/src/feeds/feeds.service.spec.ts b/apps/server/src/feeds/feeds.service.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d3b07f55b04f5d473d750c39fb8fac8df0061328
--- /dev/null
+++ b/apps/server/src/feeds/feeds.service.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { FeedsService } from './feeds.service';
+
+describe('FeedsService', () => {
+ let service: FeedsService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [FeedsService],
+ }).compile();
+
+ service = module.get(FeedsService);
+ });
+
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+});
diff --git a/apps/server/src/feeds/feeds.service.ts b/apps/server/src/feeds/feeds.service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..994711e1061a577d0d65903790847aec918799e1
--- /dev/null
+++ b/apps/server/src/feeds/feeds.service.ts
@@ -0,0 +1,291 @@
+import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '@server/prisma/prisma.service';
+import { Cron } from '@nestjs/schedule';
+import { TrpcService } from '@server/trpc/trpc.service';
+import { feedMimeTypeMap, feedTypes } from '@server/constants';
+import { ConfigService } from '@nestjs/config';
+import { Article, Feed as FeedInfo } from '@prisma/client';
+import { ConfigurationType } from '@server/configuration';
+import { Feed } from 'feed';
+import got, { Got } from 'got';
+import { load } from 'cheerio';
+import { minify } from 'html-minifier';
+import NodeCache from 'node-cache';
+import pMap from '@cjs-exporter/p-map';
+
+console.log('CRON_EXPRESSION: ', process.env.CRON_EXPRESSION);
+
+const mpCache = new NodeCache({
+ maxKeys: 1000,
+});
+
+@Injectable()
+export class FeedsService {
+ private readonly logger = new Logger(this.constructor.name);
+
+ private request: Got;
+ constructor(
+ private readonly prismaService: PrismaService,
+ private readonly trpcService: TrpcService,
+ private readonly configService: ConfigService,
+ ) {
+ this.request = got.extend({
+ retry: {
+ limit: 3,
+ methods: ['GET'],
+ },
+ timeout: 8 * 1e3,
+ headers: {
+ accept:
+ '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',
+ 'accept-encoding': 'gzip, deflate, br',
+ 'accept-language': 'en-US,en;q=0.9',
+ 'cache-control': 'max-age=0',
+ 'sec-ch-ua':
+ '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"',
+ 'sec-ch-ua-mobile': '?0',
+ 'sec-ch-ua-platform': '"macOS"',
+ 'sec-fetch-dest': 'document',
+ 'sec-fetch-mode': 'navigate',
+ 'sec-fetch-site': 'none',
+ 'sec-fetch-user': '?1',
+ 'upgrade-insecure-requests': '1',
+ 'user-agent':
+ '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',
+ },
+ hooks: {
+ beforeRetry: [
+ async (options, error, retryCount) => {
+ this.logger.warn(`retrying ${options.url}...`);
+ return new Promise((resolve) =>
+ setTimeout(resolve, 2e3 * (retryCount || 1)),
+ );
+ },
+ ],
+ },
+ });
+ }
+
+ @Cron(process.env.CRON_EXPRESSION || '35 5,17 * * *', {
+ name: 'updateFeeds',
+ timeZone: 'Asia/Shanghai',
+ })
+ async handleUpdateFeedsCron() {
+ this.logger.debug('Called handleUpdateFeedsCron');
+
+ const feeds = await this.prismaService.feed.findMany({
+ where: { status: 1 },
+ });
+ this.logger.debug('feeds length:' + feeds.length);
+
+ for (const feed of feeds) {
+ this.logger.debug('feed', feed.id);
+ try {
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(feed.id);
+ } catch (err) {
+ this.logger.error('handleUpdateFeedsCron error', err);
+ } finally {
+ // wait 30s for next feed
+ await new Promise((resolve) => setTimeout(resolve, 30 * 1e3));
+ }
+ }
+ }
+
+ async cleanHtml(source: string) {
+ const $ = load(source, { decodeEntities: false });
+
+ const dirtyHtml = $.html($('.rich_media_content'));
+
+ const html = dirtyHtml
+ .replace(/data-src=/g, 'src=')
+ .replace(/visibility: hidden;/g, '');
+
+ const content =
+ '' +
+ html;
+
+ const result = minify(content, {
+ removeAttributeQuotes: true,
+ collapseWhitespace: true,
+ });
+
+ return result;
+ }
+
+ async getHtmlByUrl(url: string) {
+ const html = await this.request(url, { responseType: 'text' }).text();
+ const result = await this.cleanHtml(html);
+
+ return result;
+ }
+
+ async tryGetContent(id: string) {
+ let content = mpCache.get(id) as string;
+ if (content) {
+ return content;
+ }
+ const url = `https://mp.weixin.qq.com/s/${id}`;
+ content = await this.getHtmlByUrl(url).catch((e) => {
+ this.logger.error(`getHtmlByUrl(${url}) error: ${e.message}`);
+
+ return '获取全文失败,请重试~';
+ });
+ mpCache.set(id, content);
+ return content;
+ }
+
+ async renderFeed({
+ type,
+ feedInfo,
+ articles,
+ mode,
+ }: {
+ type: string;
+ feedInfo: FeedInfo;
+ articles: Article[];
+ mode?: string;
+ }) {
+ const { originUrl, mode: globalMode } =
+ this.configService.get('feed')!;
+
+ const link = `${originUrl}/feeds/${feedInfo.id}.${type}`;
+
+ const feed = new Feed({
+ title: feedInfo.mpName,
+ description: feedInfo.mpIntro,
+ id: link,
+ link: link,
+ language: 'zh-cn', // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
+ image: feedInfo.mpCover,
+ favicon: feedInfo.mpCover,
+ copyright: '',
+ updated: new Date(feedInfo.updateTime * 1e3),
+ generator: 'WeWe-RSS',
+ author: { name: feedInfo.mpName },
+ });
+
+ feed.addExtension({
+ name: 'generator',
+ objects: `WeWe-RSS`,
+ });
+
+ const feeds = await this.prismaService.feed.findMany({
+ select: { id: true, mpName: true },
+ });
+
+ /**mode 高于 globalMode。如果 mode 值存在,取 mode 值*/
+ const enableFullText =
+ typeof mode === 'string'
+ ? mode === 'fulltext'
+ : globalMode === 'fulltext';
+
+ const showAuthor = feedInfo.id === 'all';
+
+ const mapper = async (item) => {
+ const { title, id, publishTime, picUrl, mpId } = item;
+ const link = `https://mp.weixin.qq.com/s/${id}`;
+
+ const mpName = feeds.find((item) => item.id === mpId)?.mpName || '-';
+ const published = new Date(publishTime * 1e3);
+
+ let description = '';
+ if (enableFullText) {
+ description = await this.tryGetContent(id);
+ }
+
+ feed.addItem({
+ id,
+ title,
+ link: link,
+ guid: link,
+ description,
+ date: published,
+ image: picUrl,
+ author: showAuthor ? [{ name: mpName }] : undefined,
+ });
+ };
+
+ await pMap(articles, mapper, { concurrency: 2, stopOnError: false });
+
+ return feed;
+ }
+
+ async handleGenerateFeed({
+ id,
+ type,
+ limit,
+ mode,
+ }: {
+ id?: string;
+ type: string;
+ limit: number;
+ mode?: string;
+ }) {
+ if (!feedTypes.includes(type as any)) {
+ type = 'atom';
+ }
+
+ let articles: Article[];
+ let feedInfo: FeedInfo;
+ if (id) {
+ feedInfo = (await this.prismaService.feed.findFirst({
+ where: { id },
+ }))!;
+
+ if (!feedInfo) {
+ throw new HttpException('不存在该feed!', HttpStatus.BAD_REQUEST);
+ }
+
+ articles = await this.prismaService.article.findMany({
+ where: { mpId: id },
+ orderBy: { publishTime: 'desc' },
+ take: limit,
+ });
+ } else {
+ articles = await this.prismaService.article.findMany({
+ orderBy: { publishTime: 'desc' },
+ take: limit,
+ });
+
+ feedInfo = {
+ id: 'all',
+ mpName: 'WeWe-RSS All',
+ mpIntro: 'WeWe-RSS 全部文章',
+ mpCover: 'https://r2-assets.111965.xyz/wewe-rss.png',
+ status: 1,
+ syncTime: 0,
+ updateTime: Math.floor(Date.now() / 1e3),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ }
+
+ this.logger.log('handleGenerateFeed articles: ' + articles.length);
+ const feed = await this.renderFeed({ feedInfo, articles, type, mode });
+
+ switch (type) {
+ case 'rss':
+ return { content: feed.rss2(), mimeType: feedMimeTypeMap[type] };
+ case 'json':
+ return { content: feed.json1(), mimeType: feedMimeTypeMap[type] };
+ case 'atom':
+ default:
+ return { content: feed.atom1(), mimeType: feedMimeTypeMap[type] };
+ }
+ }
+
+ async getFeedList() {
+ const data = await this.prismaService.feed.findMany();
+
+ return data.map((item) => {
+ return {
+ id: item.id,
+ name: item.mpName,
+ intro: item.mpIntro,
+ cover: item.mpCover,
+ syncTime: item.syncTime,
+ updateTime: item.updateTime,
+ };
+ });
+ }
+}
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
new file mode 100644
index 0000000000000000000000000000000000000000..20959802313383dab5585a062b051f4cf51a4b5d
--- /dev/null
+++ b/apps/server/src/main.ts
@@ -0,0 +1,49 @@
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from './app.module';
+import { TrpcRouter } from '@server/trpc/trpc.router';
+import { ConfigService } from '@nestjs/config';
+import { json, urlencoded } from 'express';
+import { NestExpressApplication } from '@nestjs/platform-express';
+import { ConfigurationType } from './configuration';
+import { join, resolve } from 'path';
+import { readFileSync } from 'fs';
+
+const packageJson = JSON.parse(
+ readFileSync(resolve(__dirname, '..', './package.json'), 'utf-8'),
+);
+
+const appVersion = packageJson.version;
+console.log('appVersion: v' + appVersion);
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule);
+ const configService = app.get(ConfigService);
+
+ const { host, isProd, port } =
+ configService.get('server')!;
+
+ app.use(json({ limit: '10mb' }));
+ app.use(urlencoded({ extended: true, limit: '10mb' }));
+
+ app.useStaticAssets(join(__dirname, '..', 'client', 'assets'), {
+ prefix: '/dash/assets/',
+ });
+ app.setBaseViewsDir(join(__dirname, '..', 'client'));
+ app.setViewEngine('hbs');
+
+ if (isProd) {
+ app.enable('trust proxy');
+ }
+
+ app.enableCors({
+ exposedHeaders: ['authorization'],
+ });
+
+ const trpc = app.get(TrpcRouter);
+ trpc.applyMiddleware(app);
+
+ await app.listen(port, host);
+
+ console.log(`Server is running at http://${host}:${port}`);
+}
+bootstrap();
diff --git a/apps/server/src/prisma/prisma.module.ts b/apps/server/src/prisma/prisma.module.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ec0ce3291549a316e826e073e7b028674a47a6a8
--- /dev/null
+++ b/apps/server/src/prisma/prisma.module.ts
@@ -0,0 +1,8 @@
+import { Module } from '@nestjs/common';
+import { PrismaService } from './prisma.service';
+
+@Module({
+ providers: [PrismaService],
+ exports: [PrismaService],
+})
+export class PrismaModule {}
diff --git a/apps/server/src/prisma/prisma.service.ts b/apps/server/src/prisma/prisma.service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..359f950b75c4f7f644772315a44d1108664e697d
--- /dev/null
+++ b/apps/server/src/prisma/prisma.service.ts
@@ -0,0 +1,9 @@
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import { PrismaClient } from '@prisma/client';
+
+@Injectable()
+export class PrismaService extends PrismaClient implements OnModuleInit {
+ async onModuleInit() {
+ await this.$connect();
+ }
+}
diff --git a/apps/server/src/trpc/trpc.module.ts b/apps/server/src/trpc/trpc.module.ts
new file mode 100644
index 0000000000000000000000000000000000000000..14787949e9e48a938901ced6bdd96c88b9c3aca2
--- /dev/null
+++ b/apps/server/src/trpc/trpc.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { TrpcService } from '@server/trpc/trpc.service';
+import { TrpcRouter } from '@server/trpc/trpc.router';
+import { PrismaModule } from '@server/prisma/prisma.module';
+
+@Module({
+ imports: [PrismaModule],
+ controllers: [],
+ providers: [TrpcService, TrpcRouter],
+ exports: [TrpcService, TrpcRouter],
+})
+export class TrpcModule {}
diff --git a/apps/server/src/trpc/trpc.router.ts b/apps/server/src/trpc/trpc.router.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4e12a0f661ba45d9ae4343a60ebb96a4d63b4df5
--- /dev/null
+++ b/apps/server/src/trpc/trpc.router.ts
@@ -0,0 +1,439 @@
+import { INestApplication, Injectable, Logger } from '@nestjs/common';
+import { z } from 'zod';
+import { TrpcService } from '@server/trpc/trpc.service';
+import * as trpcExpress from '@trpc/server/adapters/express';
+import { TRPCError } from '@trpc/server';
+import { PrismaService } from '@server/prisma/prisma.service';
+import { statusMap } from '@server/constants';
+import { ConfigService } from '@nestjs/config';
+import { ConfigurationType } from '@server/configuration';
+
+@Injectable()
+export class TrpcRouter {
+ constructor(
+ private readonly trpcService: TrpcService,
+ private readonly prismaService: PrismaService,
+ private readonly configService: ConfigService,
+ ) {}
+
+ private readonly logger = new Logger(this.constructor.name);
+
+ accountRouter = this.trpcService.router({
+ list: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ limit: z.number().min(1).max(500).nullish(),
+ cursor: z.string().nullish(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const limit = input.limit ?? 500;
+ const { cursor } = input;
+
+ const items = await this.prismaService.account.findMany({
+ take: limit + 1,
+ where: {},
+ select: {
+ id: true,
+ name: true,
+ status: true,
+ createdAt: true,
+ updatedAt: true,
+ token: false,
+ },
+ cursor: cursor
+ ? {
+ id: cursor,
+ }
+ : undefined,
+ orderBy: {
+ createdAt: 'asc',
+ },
+ });
+ let nextCursor: typeof cursor | undefined = undefined;
+ if (items.length > limit) {
+ // Remove the last item and use it as next cursor
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const nextItem = items.pop()!;
+ nextCursor = nextItem.id;
+ }
+
+ const disabledAccounts = this.trpcService.getBlockedAccountIds();
+ return {
+ blocks: disabledAccounts,
+ items,
+ nextCursor,
+ };
+ }),
+ byId: this.trpcService.protectedProcedure
+ .input(z.string())
+ .query(async ({ input: id }) => {
+ const account = await this.prismaService.account.findUnique({
+ where: { id },
+ });
+ if (!account) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: `No account with id '${id}'`,
+ });
+ }
+ return account;
+ }),
+ add: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ id: z.string().min(1).max(32),
+ token: z.string().min(1),
+ name: z.string().min(1),
+ status: z.number().default(statusMap.ENABLE),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { id, ...data } = input;
+ const account = await this.prismaService.account.upsert({
+ where: {
+ id,
+ },
+ update: data,
+ create: input,
+ });
+ this.trpcService.removeBlockedAccount(id);
+
+ return account;
+ }),
+ edit: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ data: z.object({
+ token: z.string().min(1).optional(),
+ name: z.string().min(1).optional(),
+ status: z.number().optional(),
+ }),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { id, data } = input;
+ const account = await this.prismaService.account.update({
+ where: { id },
+ data,
+ });
+ this.trpcService.removeBlockedAccount(id);
+ return account;
+ }),
+ delete: this.trpcService.protectedProcedure
+ .input(z.string())
+ .mutation(async ({ input: id }) => {
+ await this.prismaService.account.delete({ where: { id } });
+ this.trpcService.removeBlockedAccount(id);
+
+ return id;
+ }),
+ });
+
+ feedRouter = this.trpcService.router({
+ list: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ limit: z.number().min(1).max(500).nullish(),
+ cursor: z.string().nullish(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const limit = input.limit ?? 500;
+ const { cursor } = input;
+
+ const items = await this.prismaService.feed.findMany({
+ take: limit + 1,
+ where: {},
+ cursor: cursor
+ ? {
+ id: cursor,
+ }
+ : undefined,
+ orderBy: {
+ createdAt: 'asc',
+ },
+ });
+ let nextCursor: typeof cursor | undefined = undefined;
+ if (items.length > limit) {
+ // Remove the last item and use it as next cursor
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const nextItem = items.pop()!;
+ nextCursor = nextItem.id;
+ }
+
+ return {
+ items: items,
+ nextCursor,
+ };
+ }),
+ byId: this.trpcService.protectedProcedure
+ .input(z.string())
+ .query(async ({ input: id }) => {
+ const feed = await this.prismaService.feed.findUnique({
+ where: { id },
+ });
+ if (!feed) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: `No feed with id '${id}'`,
+ });
+ }
+ return feed;
+ }),
+ add: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ mpName: z.string(),
+ mpCover: z.string(),
+ mpIntro: z.string(),
+ syncTime: z
+ .number()
+ .optional()
+ .default(Math.floor(Date.now() / 1e3)),
+ updateTime: z.number(),
+ status: z.number().default(statusMap.ENABLE),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { id, ...data } = input;
+ const feed = await this.prismaService.feed.upsert({
+ where: {
+ id,
+ },
+ update: data,
+ create: input,
+ });
+
+ return feed;
+ }),
+ edit: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ data: z.object({
+ mpName: z.string().optional(),
+ mpCover: z.string().optional(),
+ mpIntro: z.string().optional(),
+ syncTime: z.number().optional(),
+ updateTime: z.number().optional(),
+ status: z.number().optional(),
+ }),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { id, data } = input;
+ const feed = await this.prismaService.feed.update({
+ where: { id },
+ data,
+ });
+ return feed;
+ }),
+ delete: this.trpcService.protectedProcedure
+ .input(z.string())
+ .mutation(async ({ input: id }) => {
+ await this.prismaService.feed.delete({ where: { id } });
+ return id;
+ }),
+
+ refreshArticles: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ mpId: z.string().optional(),
+ }),
+ )
+ .mutation(async ({ input: { mpId } }) => {
+ if (mpId) {
+ await this.trpcService.refreshMpArticlesAndUpdateFeed(mpId);
+ } else {
+ await this.trpcService.refreshAllMpArticlesAndUpdateFeed();
+ }
+ }),
+
+ isRefreshAllMpArticlesRunning: this.trpcService.protectedProcedure.query(
+ async () => {
+ return this.trpcService.isRefreshAllMpArticlesRunning;
+ },
+ ),
+ });
+
+ articleRouter = this.trpcService.router({
+ list: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ limit: z.number().min(1).max(500).nullish(),
+ cursor: z.string().nullish(),
+ mpId: z.string().nullish(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const limit = input.limit ?? 500;
+ const { cursor, mpId } = input;
+
+ const items = await this.prismaService.article.findMany({
+ orderBy: [
+ {
+ publishTime: 'desc',
+ },
+ ],
+ take: limit + 1,
+ where: mpId ? { mpId } : undefined,
+ cursor: cursor
+ ? {
+ id: cursor,
+ }
+ : undefined,
+ });
+ let nextCursor: typeof cursor | undefined = undefined;
+ if (items.length > limit) {
+ // Remove the last item and use it as next cursor
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const nextItem = items.pop()!;
+ nextCursor = nextItem.id;
+ }
+
+ return {
+ items,
+ nextCursor,
+ };
+ }),
+ byId: this.trpcService.protectedProcedure
+ .input(z.string())
+ .query(async ({ input: id }) => {
+ const article = await this.prismaService.article.findUnique({
+ where: { id },
+ });
+ if (!article) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: `No article with id '${id}'`,
+ });
+ }
+ return article;
+ }),
+
+ add: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ mpId: z.string(),
+ title: z.string(),
+ picUrl: z.string().optional().default(''),
+ publishTime: z.number(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { id, ...data } = input;
+ const article = await this.prismaService.article.upsert({
+ where: {
+ id,
+ },
+ update: data,
+ create: input,
+ });
+
+ return article;
+ }),
+ delete: this.trpcService.protectedProcedure
+ .input(z.string())
+ .mutation(async ({ input: id }) => {
+ await this.prismaService.article.delete({ where: { id } });
+ return id;
+ }),
+ });
+
+ platformRouter = this.trpcService.router({
+ getMpArticles: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ mpId: z.string(),
+ }),
+ )
+ .mutation(async ({ input: { mpId } }) => {
+ try {
+ const results = await this.trpcService.getMpArticles(mpId);
+ return results;
+ } catch (err: any) {
+ this.logger.log('getMpArticles err: ', err);
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: err.response?.data?.message || err.message,
+ cause: err.stack,
+ });
+ }
+ }),
+ getMpInfo: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ wxsLink: z
+ .string()
+ .refine((v) => v.startsWith('https://mp.weixin.qq.com/s/')),
+ }),
+ )
+ .mutation(async ({ input: { wxsLink: url } }) => {
+ try {
+ const results = await this.trpcService.getMpInfo(url);
+ return results;
+ } catch (err: any) {
+ this.logger.log('getMpInfo err: ', err);
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: err.response?.data?.message || err.message,
+ cause: err.stack,
+ });
+ }
+ }),
+
+ createLoginUrl: this.trpcService.protectedProcedure.mutation(async () => {
+ return this.trpcService.createLoginUrl();
+ }),
+ getLoginResult: this.trpcService.protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ }),
+ )
+ .query(async ({ input }) => {
+ return this.trpcService.getLoginResult(input.id);
+ }),
+ });
+
+ appRouter = this.trpcService.router({
+ feed: this.feedRouter,
+ account: this.accountRouter,
+ article: this.articleRouter,
+ platform: this.platformRouter,
+ });
+
+ async applyMiddleware(app: INestApplication) {
+ app.use(
+ `/trpc`,
+ trpcExpress.createExpressMiddleware({
+ router: this.appRouter,
+ createContext: ({ req }) => {
+ const authCode =
+ this.configService.get('auth')!.code;
+
+ if (authCode && req.headers.authorization !== authCode) {
+ return {
+ errorMsg: 'authCode不正确!',
+ };
+ }
+ return {
+ errorMsg: null,
+ };
+ },
+ middleware: (req, res, next) => {
+ next();
+ },
+ }),
+ );
+ }
+}
+
+export type AppRouter = TrpcRouter[`appRouter`];
diff --git a/apps/server/src/trpc/trpc.service.ts b/apps/server/src/trpc/trpc.service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c85e8b6422278b2d035c3018c81b63b302e1e64a
--- /dev/null
+++ b/apps/server/src/trpc/trpc.service.ts
@@ -0,0 +1,270 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { ConfigurationType } from '@server/configuration';
+import { statusMap } from '@server/constants';
+import { PrismaService } from '@server/prisma/prisma.service';
+import { TRPCError, initTRPC } from '@trpc/server';
+import Axios, { AxiosInstance } from 'axios';
+import dayjs from 'dayjs';
+import timezone from 'dayjs/plugin/timezone';
+import utc from 'dayjs/plugin/utc';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+/**
+ * 读书账号每日小黑屋
+ */
+const blockedAccountsMap = new Map();
+
+@Injectable()
+export class TrpcService {
+ trpc = initTRPC.create();
+ publicProcedure = this.trpc.procedure;
+ protectedProcedure = this.trpc.procedure.use(({ ctx, next }) => {
+ const errorMsg = (ctx as any).errorMsg;
+ if (errorMsg) {
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: errorMsg });
+ }
+ return next({ ctx });
+ });
+ router = this.trpc.router;
+ mergeRouters = this.trpc.mergeRouters;
+ request: AxiosInstance;
+
+ private readonly logger = new Logger(this.constructor.name);
+
+ constructor(
+ private readonly prismaService: PrismaService,
+ private readonly configService: ConfigService,
+ ) {
+ const { url } =
+ this.configService.get('platform')!;
+ this.request = Axios.create({ baseURL: url, timeout: 15 * 1e3 });
+
+ this.request.interceptors.response.use(
+ (response) => {
+ return response;
+ },
+ async (error) => {
+ this.logger.log('error: ', error);
+ const errMsg = error.response?.data?.message || '';
+
+ const id = (error.config.headers as any).xid;
+ if (errMsg.includes('WeReadError401')) {
+ // 账号失效
+ await this.prismaService.account.update({
+ where: { id },
+ data: { status: statusMap.INVALID },
+ });
+ this.logger.error(`账号(${id})登录失效,已禁用`);
+ } else if (errMsg.includes('WeReadError429')) {
+ //TODO 处理请求频繁
+ this.logger.error(`账号(${id})请求频繁,打入小黑屋`);
+ }
+
+ const today = this.getTodayDate();
+
+ const blockedAccounts = blockedAccountsMap.get(today);
+
+ if (Array.isArray(blockedAccounts)) {
+ if (id) {
+ blockedAccounts.push(id);
+ }
+ blockedAccountsMap.set(today, blockedAccounts);
+ } else if (errMsg.includes('WeReadError400')) {
+ this.logger.error(`账号(${id})处理请求参数出错`);
+ this.logger.error('WeReadError400: ', errMsg);
+ // 10s 后重试
+ await new Promise((resolve) => setTimeout(resolve, 10 * 1e3));
+ } else {
+ this.logger.error("Can't handle this error: ", errMsg);
+ }
+
+ return Promise.reject(error);
+ },
+ );
+ }
+
+ removeBlockedAccount = (vid: string) => {
+ const today = this.getTodayDate();
+
+ const blockedAccounts = blockedAccountsMap.get(today);
+ if (Array.isArray(blockedAccounts)) {
+ const newBlockedAccounts = blockedAccounts.filter((id) => id !== vid);
+ blockedAccountsMap.set(today, newBlockedAccounts);
+ }
+ };
+
+ private getTodayDate() {
+ return dayjs.tz(new Date(), 'Asia/Shanghai').format('YYYY-MM-DD');
+ }
+
+ getBlockedAccountIds() {
+ const today = this.getTodayDate();
+ const disabledAccounts = blockedAccountsMap.get(today) || [];
+ this.logger.debug('disabledAccounts: ', disabledAccounts);
+ return disabledAccounts.filter(Boolean);
+ }
+
+ private async getAvailableAccount() {
+ const disabledAccounts = this.getBlockedAccountIds();
+ const account = await this.prismaService.account.findFirst({
+ where: {
+ status: statusMap.ENABLE,
+ NOT: {
+ id: { in: disabledAccounts },
+ },
+ },
+ });
+
+ if (!account) {
+ throw new Error('暂无可用读书账号!');
+ }
+
+ return account;
+ }
+
+ async getMpArticles(mpId: string, retryCount = 3) {
+ const account = await this.getAvailableAccount();
+
+ try {
+ const res = await this.request
+ .get<
+ {
+ id: string;
+ title: string;
+ picUrl: string;
+ publishTime: number;
+ }[]
+ >(`/api/v2/platform/mps/${mpId}/articles`, {
+ headers: {
+ xid: account.id,
+ Authorization: `Bearer ${account.token}`,
+ },
+ })
+ .then((res) => res.data)
+ .then((res) => {
+ this.logger.log(`getMpArticles(${mpId}): ${res.length} articles`);
+ return res;
+ });
+ return res;
+ } catch (err) {
+ this.logger.error(`retry(${4 - retryCount}) getMpArticles error: `, err);
+ if (retryCount > 0) {
+ return this.getMpArticles(mpId, retryCount - 1);
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ async refreshMpArticlesAndUpdateFeed(mpId: string) {
+ const articles = await this.getMpArticles(mpId);
+
+ if (articles.length > 0) {
+ let results;
+ const { type } =
+ this.configService.get('database')!;
+ if (type === 'sqlite') {
+ // sqlite3 不支持 createMany
+ const inserts = articles.map(({ id, picUrl, publishTime, title }) =>
+ this.prismaService.article.upsert({
+ create: { id, mpId, picUrl, publishTime, title },
+ update: {
+ publishTime,
+ title,
+ },
+ where: { id },
+ }),
+ );
+ results = await this.prismaService.$transaction(inserts);
+ } else {
+ results = await (this.prismaService.article as any).createMany({
+ data: articles.map(({ id, picUrl, publishTime, title }) => ({
+ id,
+ mpId,
+ picUrl,
+ publishTime,
+ title,
+ })),
+ skipDuplicates: true,
+ });
+ }
+
+ this.logger.debug('refreshMpArticlesAndUpdateFeed results: ', results);
+ }
+
+ await this.prismaService.feed.update({
+ where: { id: mpId },
+ data: {
+ syncTime: Math.floor(Date.now() / 1e3),
+ },
+ });
+ }
+
+ isRefreshAllMpArticlesRunning = false;
+
+ async refreshAllMpArticlesAndUpdateFeed() {
+ if (this.isRefreshAllMpArticlesRunning) {
+ this.logger.log('refreshAllMpArticlesAndUpdateFeed is running');
+ return;
+ }
+ const mps = await this.prismaService.feed.findMany();
+ this.isRefreshAllMpArticlesRunning = true;
+ try {
+ for (const { id } of mps) {
+ await this.refreshMpArticlesAndUpdateFeed(id);
+ await new Promise((resolve) => setTimeout(resolve, 10 * 1e3));
+ }
+ } finally {
+ this.isRefreshAllMpArticlesRunning = false;
+ }
+ }
+
+ async getMpInfo(url: string) {
+ url = url.trim();
+ const account = await this.getAvailableAccount();
+
+ return this.request
+ .post<
+ {
+ id: string;
+ cover: string;
+ name: string;
+ intro: string;
+ updateTime: number;
+ }[]
+ >(
+ `/api/v2/platform/wxs2mp`,
+ { url },
+ {
+ headers: {
+ xid: account.id,
+ Authorization: `Bearer ${account.token}`,
+ },
+ },
+ )
+ .then((res) => res.data);
+ }
+
+ async createLoginUrl() {
+ return this.request
+ .get<{
+ uuid: string;
+ scanUrl: string;
+ }>(`/api/v2/login/platform`)
+ .then((res) => res.data);
+ }
+
+ async getLoginResult(id: string) {
+ return this.request
+ .get<{
+ message: string;
+ vid?: number;
+ token?: string;
+ username?: string;
+ }>(`/api/v2/login/platform/${id}`, { timeout: 120 * 1e3 })
+ .then((res) => res.data);
+ }
+}
diff --git a/apps/server/test/app.e2e-spec.ts b/apps/server/test/app.e2e-spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..50cda62332e9474925e819ff946358a9c40d1bf2
--- /dev/null
+++ b/apps/server/test/app.e2e-spec.ts
@@ -0,0 +1,24 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { INestApplication } from '@nestjs/common';
+import * as request from 'supertest';
+import { AppModule } from './../src/app.module';
+
+describe('AppController (e2e)', () => {
+ let app: INestApplication;
+
+ beforeEach(async () => {
+ const moduleFixture: TestingModule = await Test.createTestingModule({
+ imports: [AppModule],
+ }).compile();
+
+ app = moduleFixture.createNestApplication();
+ await app.init();
+ });
+
+ it('/ (GET)', () => {
+ return request(app.getHttpServer())
+ .get('/')
+ .expect(200)
+ .expect('Hello World!');
+ });
+});
diff --git a/apps/server/test/jest-e2e.json b/apps/server/test/jest-e2e.json
new file mode 100644
index 0000000000000000000000000000000000000000..e9d912f3e3cefc18505d3cd19b3a5a9f567f5de0
--- /dev/null
+++ b/apps/server/test/jest-e2e.json
@@ -0,0 +1,9 @@
+{
+ "moduleFileExtensions": ["js", "json", "ts"],
+ "rootDir": ".",
+ "testEnvironment": "node",
+ "testRegex": ".e2e-spec.ts$",
+ "transform": {
+ "^.+\\.(t|j)s$": "ts-jest"
+ }
+}
diff --git a/apps/server/tsconfig.build.json b/apps/server/tsconfig.build.json
new file mode 100644
index 0000000000000000000000000000000000000000..64f86c6bd2bb30e3d22e752295eb7c7923fc191e
--- /dev/null
+++ b/apps/server/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
+}
diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..71be589062d2ae6c1081590afadadffe69726a03
--- /dev/null
+++ b/apps/server/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "declaration": true,
+ "removeComments": true,
+ "allowSyntheticDefaultImports": true,
+ "target": "ES2021",
+ "sourceMap": true,
+ "outDir": "./dist",
+ "esModuleInterop":true
+ }
+}
\ No newline at end of file
diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example
new file mode 100644
index 0000000000000000000000000000000000000000..919751f19d10cedbe38fe887e8bb85df46de0816
--- /dev/null
+++ b/apps/web/.env.local.example
@@ -0,0 +1,2 @@
+# 同SERVER_ORIGIN_URL
+VITE_SERVER_ORIGIN_URL=http://localhost:4000
diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs
new file mode 100644
index 0000000000000000000000000000000000000000..c50b1397dea6b796edf65f145d23987b3b8d89c9
--- /dev/null
+++ b/apps/web/.eslintrc.cjs
@@ -0,0 +1,19 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ '@typescript-eslint/no-explicit-any': 'warn',
+ },
+};
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1
--- /dev/null
+++ b/apps/web/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/apps/web/README.md b/apps/web/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..0d6babeddbdbc9d9ac5bd4d57004229d22dbd864
--- /dev/null
+++ b/apps/web/README.md
@@ -0,0 +1,30 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@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
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
+
+- Configure the top-level `parserOptions` property like this:
+
+```js
+export default {
+ // other rules...
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ project: ['./tsconfig.json', './tsconfig.node.json'],
+ tsconfigRootDir: __dirname,
+ },
+}
+```
+
+- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
+- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
+- 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
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..ff0c01593f7a7bd41f935b0ba836f3adb8e32c6c
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ WeWe RSS
+
+
+
+
+
+
+
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..1046a9cd8b01e9f692fcb71545df4119445ff897
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "web",
+ "private": true,
+ "version": "2.2.3",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@nextui-org/react": "^2.2.9",
+ "@tanstack/react-query": "^4.35.3",
+ "@trpc/client": "^10.45.1",
+ "@trpc/next": "^10.45.1",
+ "@trpc/react-query": "^10.45.1",
+ "autoprefixer": "^10.0.1",
+ "dayjs": "^1.11.10",
+ "framer-motion": "^11.0.5",
+ "next-themes": "^0.2.1",
+ "postcss": "^8",
+ "qrcode.react": "^3.1.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.22.2",
+ "sonner": "^1.4.0",
+ "tailwindcss": "^3.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.11.24",
+ "@types/react": "^18.2.56",
+ "@types/react-dom": "^18.2.19",
+ "@typescript-eslint/eslint-plugin": "^7.0.2",
+ "@typescript-eslint/parser": "^7.0.2",
+ "@vitejs/plugin-react": "^4.2.1",
+ "eslint": "^8.56.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.5",
+ "typescript": "^5.2.2",
+ "vite": "^5.1.4"
+ }
+}
\ No newline at end of file
diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..2e7af2b7f1a6f391da1631d93968a9d487ba977d
--- /dev/null
+++ b/apps/web/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d92ee1250495a9e9cba24fa273cf34aa6fbc77b9
--- /dev/null
+++ b/apps/web/src/App.tsx
@@ -0,0 +1,28 @@
+import { BrowserRouter, Route, Routes } from 'react-router-dom';
+import Feeds from './pages/feeds';
+import Login from './pages/login';
+import Accounts from './pages/accounts';
+import { BaseLayout } from './layouts/base';
+import { TrpcProvider } from './provider/trpc';
+import ThemeProvider from './provider/theme';
+
+function App() {
+ return (
+
+
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/apps/web/src/components/GitHubIcon.tsx b/apps/web/src/components/GitHubIcon.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..35ec9acd6947d955a36dcacd9fea04aac4eb0aca
--- /dev/null
+++ b/apps/web/src/components/GitHubIcon.tsx
@@ -0,0 +1,26 @@
+import { IconSvgProps } from '../types';
+
+export const GitHubIcon = ({
+ size = 24,
+ width,
+ height,
+ ...props
+}: IconSvgProps) => (
+
+);
diff --git a/apps/web/src/components/Nav.tsx b/apps/web/src/components/Nav.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..739164ea2106969aa377cc443d0332e72b08696d
--- /dev/null
+++ b/apps/web/src/components/Nav.tsx
@@ -0,0 +1,112 @@
+import {
+ Badge,
+ Image,
+ Link,
+ Navbar,
+ NavbarBrand,
+ NavbarContent,
+ NavbarItem,
+ Tooltip,
+} from '@nextui-org/react';
+import { ThemeSwitcher } from './ThemeSwitcher';
+import { GitHubIcon } from './GitHubIcon';
+import { useLocation } from 'react-router-dom';
+import { appVersion } from '@web/utils/env';
+import { useEffect, useState } from 'react';
+
+const navbarItemLink = [
+ {
+ href: '/feeds',
+ name: '公众号源',
+ },
+ {
+ href: '/accounts',
+ name: '账号管理',
+ },
+ // {
+ // href: '/settings',
+ // name: '设置',
+ // },
+];
+
+const Nav = () => {
+ const { pathname } = useLocation();
+ const [releaseVersion, setReleaseVersion] = useState(appVersion);
+
+ useEffect(() => {
+ fetch('https://api.github.com/repos/cooderl/wewe-rss/releases/latest')
+ .then((res) => res.json())
+ .then((data) => {
+ setReleaseVersion(data.name.replace('v', ''));
+ });
+ }, []);
+
+ const isFoundNewVersion = releaseVersion > appVersion;
+ console.log('isFoundNewVersion: ', isFoundNewVersion);
+
+ return (
+
+
+
+ {isFoundNewVersion && (
+
+ 发现新版本:v{releaseVersion}
+
+ )}
+ 当前版本: v{appVersion}
+
+ }
+ placement="left"
+ >
+
+
+
+
+ WeWe RSS
+
+
+
+ {navbarItemLink.map((item) => {
+ return (
+
+
+ {item.name}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Nav;
diff --git a/apps/web/src/components/PlusIcon.tsx b/apps/web/src/components/PlusIcon.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..093f5d708f76b76268dd07b938fe2eadfdaa6eab
--- /dev/null
+++ b/apps/web/src/components/PlusIcon.tsx
@@ -0,0 +1,30 @@
+import { IconSvgProps } from '../types';
+
+export const PlusIcon = ({
+ size = 24,
+ width,
+ height,
+ ...props
+}: IconSvgProps) => (
+
+);
diff --git a/apps/web/src/components/StatusDropdown.tsx b/apps/web/src/components/StatusDropdown.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..10c0f960e995419d973e15b48fa1842a613dbb5a
--- /dev/null
+++ b/apps/web/src/components/StatusDropdown.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import {
+ Dropdown,
+ DropdownTrigger,
+ DropdownMenu,
+ DropdownItem,
+ Button,
+} from '@nextui-org/react';
+import { statusMap } from '@web/constants';
+
+export function StatusDropdown({
+ value = 1,
+ onChange,
+}: {
+ value: number;
+ onChange: (value: number) => void;
+}) {
+ return (
+
+
+
+
+ {
+ onChange(+Array.from(keys)[0]);
+ }}
+ >
+ {Object.entries(statusMap).map(([key, value]) => {
+ return (
+
+ {value.label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/src/components/ThemeSwitcher.tsx b/apps/web/src/components/ThemeSwitcher.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3e8fb0bb7467447a79fa182f5152e45df9a94191
--- /dev/null
+++ b/apps/web/src/components/ThemeSwitcher.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { VisuallyHidden, useSwitch } from '@nextui-org/react';
+import { useTheme } from 'next-themes';
+
+export const MoonIcon = (props) => (
+
+);
+
+export const SunIcon = (props) => (
+
+);
+
+export function ThemeSwitcher(props) {
+ const { setTheme, theme } = useTheme();
+ const {
+ Component,
+ slots,
+ isSelected,
+ getBaseProps,
+ getInputProps,
+ getWrapperProps,
+ } = useSwitch({
+ onClick: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
+ isSelected: theme === 'dark',
+ });
+
+ return (
+
+
+
+
+
+
+ {isSelected ? : }
+
+
+
+ );
+}
diff --git a/apps/web/src/constants.ts b/apps/web/src/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ca2f21e7489ef6d4f6985ea1e773990f0c85eb81
--- /dev/null
+++ b/apps/web/src/constants.ts
@@ -0,0 +1,5 @@
+export const statusMap = {
+ 0: { label: '失效', color: 'danger' },
+ 1: { label: '启用', color: 'success' },
+ 2: { label: '禁用', color: 'warning' },
+} as const;
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..b5c61c956711f981a41e95f7fcf0038436cfbb22
--- /dev/null
+++ b/apps/web/src/index.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/apps/web/src/layouts/base.tsx b/apps/web/src/layouts/base.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d90152f385e23e4fced6b43051bad34bef0a810f
--- /dev/null
+++ b/apps/web/src/layouts/base.tsx
@@ -0,0 +1,18 @@
+import { Toaster } from 'sonner';
+import { Outlet } from 'react-router-dom';
+
+import Nav from '../components/Nav';
+
+export function BaseLayout() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0fe899540d624ab463da0aa52510f8875dd17fee
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,5 @@
+import ReactDOM from 'react-dom/client';
+import App from './App.tsx';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render();
diff --git a/apps/web/src/pages/accounts/index.tsx b/apps/web/src/pages/accounts/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..83d3cc6096500b17d4455079ea83fc19639a2740
--- /dev/null
+++ b/apps/web/src/pages/accounts/index.tsx
@@ -0,0 +1,221 @@
+import {
+ Modal,
+ ModalContent,
+ ModalHeader,
+ ModalBody,
+ Button,
+ useDisclosure,
+ Spinner,
+ Table,
+ TableBody,
+ TableCell,
+ TableColumn,
+ TableHeader,
+ TableRow,
+ Chip,
+} from '@nextui-org/react';
+import { QRCodeSVG } from 'qrcode.react';
+import { toast } from 'sonner';
+import { PlusIcon } from '@web/components/PlusIcon';
+import dayjs from 'dayjs';
+import { StatusDropdown } from '@web/components/StatusDropdown';
+import { trpc } from '@web/utils/trpc';
+import { statusMap } from '@web/constants';
+import { useEffect, useState } from 'react';
+
+const AccountPage = () => {
+ const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();
+ const [count, setCount] = useState(0);
+
+ const { refetch, data, isFetching } = trpc.account.list.useQuery({});
+
+ const queryUtils = trpc.useUtils();
+
+ const { mutateAsync: updateAccount } = trpc.account.edit.useMutation({});
+
+ const { mutateAsync: deleteAccount } = trpc.account.delete.useMutation({});
+
+ const { mutateAsync: addAccount } = trpc.account.add.useMutation({});
+
+ const { mutateAsync, data: loginData } =
+ trpc.platform.createLoginUrl.useMutation({
+ onSuccess(data) {
+ if (data.uuid) {
+ setCount(60);
+ }
+ },
+ });
+
+ const { data: loginResult } = trpc.platform.getLoginResult.useQuery(
+ {
+ id: loginData?.uuid ?? '',
+ },
+ {
+ refetchIntervalInBackground: false,
+ enabled: !!loginData?.uuid,
+ async onSuccess(data) {
+ if (data.vid && data.token) {
+ const name = data.username!;
+ await addAccount({ id: `${data.vid}`, name, token: data.token });
+
+ onClose();
+ toast.success('添加成功', {
+ description: `用户名:${name}(${data.vid})`,
+ });
+ refetch();
+ } else if (data.message) {
+ toast.error(`登录失败: ${data.message}`);
+ }
+ },
+ },
+ );
+
+ useEffect(() => {
+ let timerId;
+ if (count > 0 && isOpen) {
+ timerId = setTimeout(() => {
+ setCount(count - 1);
+ }, 1000);
+ }
+ return () => timerId && clearTimeout(timerId);
+ }, [count, isOpen]);
+
+ return (
+
+
+
共{data?.items.length || 0}个账号
+
+
+
+
+ ID
+ 用户名
+ 状态
+ 更新时间
+ 操作
+
+ 暂无数据}
+ isLoading={isFetching}
+ loadingContent={}
+ >
+ {data?.items.map((item) => {
+ const isBlocked = data?.blocks.includes(item.id);
+
+ return (
+
+ {item.id}
+ {item.name}
+
+ {isBlocked ? (
+
+ 今日小黑屋
+
+ ) : (
+
+ {statusMap[item.status].label}
+
+ )}
+
+
+ {dayjs(item.updatedAt).format('YYYY-MM-DD')}
+
+
+ {
+ updateAccount({
+ id: item.id,
+ data: { status: value },
+ }).then(() => {
+ toast.success('更新成功!');
+ refetch();
+ });
+ }}
+ >
+
+
+
+
+ );
+ }) || []}
+
+
+
+
{
+ onOpenChange();
+ await queryUtils.platform.getLoginResult.cancel();
+ }}
+ >
+
+ {() => (
+ <>
+
+ 添加读书账号
+
+
+
+ {loginData ? (
+
+
+ {loginResult?.message && (
+
+
+ {loginResult?.message}
+
+
+ )}
+
+
+
+ 微信扫码登录{' '}
+ {!loginResult?.message && count > 0 && (
+ ({count}s)
+ )}
+
+
+ ) : (
+
+
+ 二维码加载中
+
+ )}
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default AccountPage;
diff --git a/apps/web/src/pages/feeds/index.tsx b/apps/web/src/pages/feeds/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..15f432a332d2749c2ee11686e1036b53ae24dfad
--- /dev/null
+++ b/apps/web/src/pages/feeds/index.tsx
@@ -0,0 +1,368 @@
+import {
+ Avatar,
+ Button,
+ Divider,
+ Listbox,
+ ListboxItem,
+ ListboxSection,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ Switch,
+ Textarea,
+ Tooltip,
+ useDisclosure,
+ Link,
+} from '@nextui-org/react';
+import { PlusIcon } from '@web/components/PlusIcon';
+import { trpc } from '@web/utils/trpc';
+import { useMemo, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { toast } from 'sonner';
+import dayjs from 'dayjs';
+import { serverOriginUrl } from '@web/utils/env';
+import ArticleList from './list';
+
+const Feeds = () => {
+ const { id } = useParams();
+
+ const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
+ const { refetch: refetchFeedList, data: feedData } = trpc.feed.list.useQuery(
+ {},
+ {
+ refetchOnWindowFocus: true,
+ },
+ );
+
+ const navigate = useNavigate();
+
+ const queryUtils = trpc.useUtils();
+
+ const { mutateAsync: getMpInfo, isLoading: isGetMpInfoLoading } =
+ trpc.platform.getMpInfo.useMutation({});
+ const { mutateAsync: updateMpInfo } = trpc.feed.edit.useMutation({});
+
+ const { mutateAsync: addFeed, isLoading: isAddFeedLoading } =
+ trpc.feed.add.useMutation({});
+ const { mutateAsync: refreshMpArticles, isLoading: isGetArticlesLoading } =
+ trpc.feed.refreshArticles.useMutation();
+
+ const { data: isRefreshAllMpArticlesRunning } =
+ trpc.feed.isRefreshAllMpArticlesRunning.useQuery();
+
+ const { mutateAsync: deleteFeed, isLoading: isDeleteFeedLoading } =
+ trpc.feed.delete.useMutation({});
+
+ const [wxsLink, setWxsLink] = useState('');
+
+ const [currentMpId, setCurrentMpId] = useState(id || '');
+
+ const handleConfirm = async () => {
+ // TODO show operation in progress
+ const res = await getMpInfo({ wxsLink: wxsLink });
+ if (res[0]) {
+ const item = res[0];
+ await addFeed({
+ id: item.id,
+ mpName: item.name,
+ mpCover: item.cover,
+ mpIntro: item.intro,
+ updateTime: item.updateTime,
+ status: 1,
+ });
+ await refreshMpArticles({ mpId: item.id });
+
+ toast.success('添加成功', {
+ description: `公众号 ${item.name}`,
+ });
+ refetchFeedList();
+ setWxsLink('');
+ onClose();
+ await queryUtils.article.list.reset();
+ } else {
+ toast.error('添加失败', { description: '请检查链接是否正确' });
+ }
+ };
+
+ const isActive = (key: string) => {
+ return currentMpId === key;
+ };
+
+ const currentMpInfo = useMemo(() => {
+ return feedData?.items.find((item) => item.id === currentMpId);
+ }, [currentMpId, feedData?.items]);
+
+ const handleExportOpml = async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (!feedData?.items?.length) {
+ console.warn('没有订阅源');
+ return;
+ }
+
+ let opmlContent = `
+
+
+ WeWeRSS 所有订阅源
+
+
+ `;
+
+ feedData?.items.forEach((sub) => {
+ opmlContent += ` \n`;
+ });
+
+ opmlContent += `
+ `;
+
+ const blob = new Blob([opmlContent], { type: 'text/xml;charset=utf-8;' });
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = 'WeWeRSS-All.opml';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ return (
+ <>
+
+
+
+
}
+ >
+ 添加
+
+
+ 共{feedData?.items.length || 0}个订阅
+
+
+
+ {feedData?.items ? (
+
setCurrentMpId(key as string)}
+ >
+
+ }
+ >
+ 全部
+
+
+
+
+ {feedData?.items.map((item) => {
+ return (
+ }
+ >
+ {item.mpName}
+
+ );
+ }) || []}
+
+
+ ) : (
+ ''
+ )}
+
+
+
+
+ {currentMpInfo?.mpName || '全部'}
+
+ {currentMpInfo ? (
+
+
+ 最后更新时间:
+ {dayjs(currentMpInfo.syncTime * 1e3).format(
+ 'YYYY-MM-DD HH:mm:ss',
+ )}
+
+
+
+ {
+ ev.preventDefault();
+ ev.stopPropagation();
+ await refreshMpArticles({ mpId: currentMpInfo.id });
+ await refetchFeedList();
+ await queryUtils.article.list.reset();
+ }}
+ >
+ {isGetArticlesLoading ? '更新中...' : '立即更新'}
+
+
+
+
+
+
+ {
+ await updateMpInfo({
+ id: currentMpInfo.id,
+ data: {
+ status: value ? 1 : 0,
+ },
+ });
+
+ await refetchFeedList();
+ }}
+ isSelected={currentMpInfo?.status === 1}
+ >
+
+
+
+
+ {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (window.confirm('确定删除吗?')) {
+ await deleteFeed(currentMpInfo.id);
+ navigate('/feeds');
+ await refetchFeedList();
+ }
+ }}
+ >
+ 删除
+
+
+
+
+
可添加.atom/.rss/.json格式输出}>
+
+ RSS
+
+
+
+ ) : (
+
+
+ {
+ ev.preventDefault();
+ ev.stopPropagation();
+ await refreshMpArticles({});
+ await refetchFeedList();
+ await queryUtils.article.list.reset();
+ }}
+ >
+ {isRefreshAllMpArticlesRunning || isGetArticlesLoading
+ ? '更新中...'
+ : '更新全部'}
+
+
+
+ 导出OPML
+
+
+
+ RSS
+
+
+ )}
+
+
+
+
+
+
+ {(onClose) => (
+ <>
+
+ 添加公众号源
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ >
+ );
+};
+
+export default Feeds;
diff --git a/apps/web/src/pages/feeds/list.tsx b/apps/web/src/pages/feeds/list.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fd26384ec4c939086bcb34efa760d9a852a3d79a
--- /dev/null
+++ b/apps/web/src/pages/feeds/list.tsx
@@ -0,0 +1,115 @@
+import { FC, useMemo } from 'react';
+import {
+ Table,
+ TableHeader,
+ TableColumn,
+ TableBody,
+ TableRow,
+ TableCell,
+ getKeyValue,
+ Button,
+ Spinner,
+ Link,
+} from '@nextui-org/react';
+import { trpc } from '@web/utils/trpc';
+import dayjs from 'dayjs';
+import { useParams } from 'react-router-dom';
+
+const ArticleList: FC = () => {
+ const { id } = useParams();
+
+ const mpId = id || '';
+
+ const { data, fetchNextPage, isLoading, hasNextPage } =
+ trpc.article.list.useInfiniteQuery(
+ {
+ limit: 20,
+ mpId: mpId,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ );
+
+ const items = useMemo(() => {
+ const items = data
+ ? data.pages.reduce((acc, page) => [...acc, ...page.items], [] as any[])
+ : [];
+
+ return items;
+ }, [data]);
+
+ return (
+
+
+
+
+ ) : null
+ }
+ >
+
+ 标题
+
+ 发布时间
+
+
+ }
+ >
+ {(item) => (
+
+ {(columnKey) => {
+ let value = getKeyValue(item, columnKey);
+
+ if (columnKey === 'publishTime') {
+ value = dayjs(value * 1e3).format('YYYY-MM-DD HH:mm:ss');
+ return {value};
+ }
+
+ if (columnKey === 'title') {
+ return (
+
+
+ {value}
+
+
+ );
+ }
+ return {value};
+ }}
+
+ )}
+
+
+
+ );
+};
+
+export default ArticleList;
diff --git a/apps/web/src/pages/login/index.tsx b/apps/web/src/pages/login/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4866e1956aa266298c3d1b1c6863906c77db7104
--- /dev/null
+++ b/apps/web/src/pages/login/index.tsx
@@ -0,0 +1,32 @@
+import { Button, Input } from '@nextui-org/react';
+import { setAuthCode } from '@web/utils/auth';
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+const LoginPage = () => {
+ const [codeValue, setCodeValue] = useState('');
+
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+ );
+};
+
+export default LoginPage;
diff --git a/apps/web/src/provider/theme.tsx b/apps/web/src/provider/theme.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0eeddd6f7ca8b441ecfde3420fb6ca98a8c4d640
--- /dev/null
+++ b/apps/web/src/provider/theme.tsx
@@ -0,0 +1,17 @@
+import { NextUIProvider } from '@nextui-org/react';
+import { ThemeProvider as NextThemesProvider } from 'next-themes';
+import { useNavigate } from 'react-router-dom';
+
+function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const navigate = useNavigate();
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export default ThemeProvider;
diff --git a/apps/web/src/provider/trpc.tsx b/apps/web/src/provider/trpc.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..673a434a10be0e6f773495c06ab8d0d98be5cf4d
--- /dev/null
+++ b/apps/web/src/provider/trpc.tsx
@@ -0,0 +1,108 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { httpBatchLink, loggerLink } from '@trpc/client';
+import { useNavigate } from 'react-router-dom';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { isTRPCClientError, trpc } from '../utils/trpc';
+import { getAuthCode, setAuthCode } from '../utils/auth';
+import { enabledAuthCode, serverOriginUrl } from '../utils/env';
+
+export const TrpcProvider: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ const navigate = useNavigate();
+
+ const handleNoAuth = () => {
+ if (enabledAuthCode) {
+ setAuthCode('');
+ navigate('/login');
+ }
+ };
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: true,
+ refetchIntervalInBackground: false,
+ retryDelay: (retryCount) => Math.min(retryCount * 1000, 60 * 1000),
+ retry(failureCount, error) {
+ console.log('failureCount: ', failureCount);
+ if (isTRPCClientError(error)) {
+ if (error.data?.httpStatus === 401) {
+ return false;
+ }
+ }
+ return failureCount < 3;
+ },
+ onError(error) {
+ console.error('queries onError: ', error);
+ if (isTRPCClientError(error)) {
+ if (error.data?.httpStatus === 401) {
+ toast.error('无权限', {
+ description: error.message,
+ });
+
+ handleNoAuth();
+ } else {
+ toast.error('请求失败!', {
+ description: error.message,
+ });
+ }
+ }
+ },
+ },
+ mutations: {
+ onError(error) {
+ console.error('mutations onError: ', error);
+ if (isTRPCClientError(error)) {
+ if (error.data?.httpStatus === 401) {
+ toast.error('无权限', {
+ description: error.message,
+ });
+ handleNoAuth();
+ } else {
+ toast.error('请求失败!', {
+ description: error.message,
+ });
+ }
+ }
+ },
+ },
+ },
+ }),
+ );
+
+ const [trpcClient] = useState(() =>
+ trpc.createClient({
+ links: [
+ loggerLink({
+ enabled: () => true,
+ }),
+ httpBatchLink({
+ url: serverOriginUrl + '/trpc',
+ async headers() {
+ const token = getAuthCode();
+
+ if (!token) {
+ handleNoAuth();
+ return {};
+ }
+
+ return token
+ ? {
+ Authorization: `${token}`,
+ }
+ : {};
+ },
+ }),
+ ],
+ }),
+ );
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e7735f050c7f421e58d99bf26e8cda6df8390633
--- /dev/null
+++ b/apps/web/src/types.ts
@@ -0,0 +1,5 @@
+import { SVGProps } from 'react';
+
+export type IconSvgProps = SVGProps & {
+ size?: number;
+};
diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..40e4d6ba81f2e4380f3dd699069ef2471e43e65c
--- /dev/null
+++ b/apps/web/src/utils/auth.ts
@@ -0,0 +1,19 @@
+let token: string | null = null;
+
+export const getAuthCode = () => {
+ if (token !== null) {
+ return token;
+ }
+
+ token = window.localStorage.getItem('authCode');
+ return token;
+};
+
+export const setAuthCode = (authCode: string | null) => {
+ token = authCode;
+ if (!authCode) {
+ window.localStorage.removeItem('authCode');
+ return;
+ }
+ window.localStorage.setItem('authCode', authCode);
+};
diff --git a/apps/web/src/utils/env.ts b/apps/web/src/utils/env.ts
new file mode 100644
index 0000000000000000000000000000000000000000..19a5d0c4149803273fdc3b5d32ef15bc2a49348d
--- /dev/null
+++ b/apps/web/src/utils/env.ts
@@ -0,0 +1,10 @@
+export const isProd = import.meta.env.PROD;
+
+export const serverOriginUrl = isProd
+ ? window.__WEWE_RSS_SERVER_ORIGIN_URL__
+ : import.meta.env.VITE_SERVER_ORIGIN_URL;
+
+export const appVersion = __APP_VERSION__;
+
+export const enabledAuthCode =
+ window.__WEWE_RSS_ENABLED_AUTH_CODE__ === false ? false : true;
diff --git a/apps/web/src/utils/trpc.ts b/apps/web/src/utils/trpc.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1eb9862ac11fbcca04639b4fe6c04260d64f730c
--- /dev/null
+++ b/apps/web/src/utils/trpc.ts
@@ -0,0 +1,10 @@
+import { AppRouter } from '@server/trpc/trpc.router';
+import { TRPCClientError, createTRPCReact } from '@trpc/react-query';
+
+export const trpc = createTRPCReact();
+
+export function isTRPCClientError(
+ cause: unknown,
+): cause is TRPCClientError {
+ return cause instanceof TRPCClientError;
+}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..401df1d782873d8d73d655dd1fe3ef69a0cbbbd6
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1,13 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_SERVER_ORIGIN_URL: string;
+ readonly VITE_ENV: string;
+}
+
+interface Window {
+ __WEWE_RSS_SERVER_ORIGIN_URL__?: string;
+ __WEWE_RSS_ENABLED_AUTH_CODE__?: boolean;
+}
+
+declare const __APP_VERSION__: string;
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..62b4a9e3f999f2623ab227455effa80e8a83dd66
--- /dev/null
+++ b/apps/web/tailwind.config.ts
@@ -0,0 +1,16 @@
+import type { Config } from 'tailwindcss';
+import { nextui } from '@nextui-org/react';
+
+const config: Config = {
+ content: [
+ './index.html',
+ './src/**/*.{js,ts,jsx,tsx}',
+ '../../node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}',
+ ],
+ theme: {
+ extend: {},
+ },
+ darkMode: 'class',
+ plugins: [nextui()],
+};
+export default config;
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..21b4051e5d1123bf5f9090c2e322497ddc64a2f4
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitAny": false
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json
new file mode 100644
index 0000000000000000000000000000000000000000..97ede7ee6f2d37bd2d76e60c0b6a447bee718b05
--- /dev/null
+++ b/apps/web/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..afe8d16de9a0df1cd4b2a7888982c8c49a408f4e
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,51 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { resolve } from 'path';
+import { readFileSync } from 'fs';
+
+const projectRootDir = resolve(__dirname);
+
+const isProd = process.env.NODE_ENV === 'production';
+
+console.log('process.env.NODE_ENV: ', process.env.NODE_ENV);
+
+const packageJson = JSON.parse(
+ readFileSync(resolve(__dirname, './package.json'), 'utf-8'),
+);
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ base: '/dash',
+ define: {
+ __APP_VERSION__: JSON.stringify(packageJson.version),
+ },
+ plugins: [
+ react(),
+ !isProd
+ ? null
+ : {
+ name: 'renameIndex',
+ enforce: 'post',
+ generateBundle(options, bundle) {
+ const indexHtml = bundle['index.html'];
+ indexHtml.fileName = 'index.hbs';
+ },
+ },
+ ],
+ resolve: {
+ alias: [
+ {
+ find: '@server',
+ replacement: resolve(projectRootDir, '../apps/server/src'),
+ },
+ {
+ find: '@web',
+ replacement: resolve(projectRootDir, './src'),
+ },
+ ],
+ },
+ build: {
+ emptyOutDir: true,
+ outDir: resolve(projectRootDir, '..', 'server', 'client'),
+ },
+});
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..a5d2376af7a9014cb8092725eb0e2cd87e1912fd
Binary files /dev/null and b/assets/logo.png differ
diff --git a/assets/nginx.example.conf b/assets/nginx.example.conf
new file mode 100644
index 0000000000000000000000000000000000000000..dc40c834180e4cd67a64ccac9952c400503296b1
--- /dev/null
+++ b/assets/nginx.example.conf
@@ -0,0 +1,29 @@
+server {
+
+ listen 80;
+
+ server_name yourdomain;
+
+ location / {
+
+ proxy_pass http://127.0.0.1:4000;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Accept-Encoding gzip;
+
+ proxy_buffering off;
+ proxy_cache off;
+
+ send_timeout 300;
+ proxy_connect_timeout 300;
+ proxy_send_timeout 300;
+ proxy_read_timeout 300;
+ }
+
+}
+
+
diff --git a/assets/preview1.png b/assets/preview1.png
new file mode 100644
index 0000000000000000000000000000000000000000..ec831eba95eab988223ee08b135fdb29d9d845ec
Binary files /dev/null and b/assets/preview1.png differ
diff --git a/assets/preview2.png b/assets/preview2.png
new file mode 100644
index 0000000000000000000000000000000000000000..2edaf444e2fc68107869e2fa6a43f6cf0c3c1219
Binary files /dev/null and b/assets/preview2.png differ
diff --git a/assets/preview3.png b/assets/preview3.png
new file mode 100644
index 0000000000000000000000000000000000000000..869fae6339e5d9ac8d2804db29c4cdcb97c6cfde
Binary files /dev/null and b/assets/preview3.png differ