├── .husky ├── .gitignore └── pre-commit ├── .eslintignore ├── .prettierignore ├── .dockerignore ├── apps ├── server │ ├── src │ │ ├── shared │ │ │ ├── uploads │ │ │ │ ├── dto │ │ │ │ │ ├── image.dto.ts │ │ │ │ │ └── filetype.dto.ts │ │ │ │ ├── uploads.module.ts │ │ │ │ ├── image.service.ts │ │ │ │ └── uploads.controller.ts │ │ │ ├── base │ │ │ │ ├── interfaces │ │ │ │ │ └── index.ts │ │ │ │ └── dto │ │ │ │ │ ├── id.dto.ts │ │ │ │ │ ├── search.dto.ts │ │ │ │ │ └── pager.dto.ts │ │ │ ├── comments │ │ │ │ ├── dto │ │ │ │ │ ├── state.dto.ts │ │ │ │ │ ├── pager.dto.ts │ │ │ │ │ └── comment.dto.ts │ │ │ │ └── comments.module.ts │ │ │ ├── aggregate │ │ │ │ └── dtos │ │ │ │ │ ├── top.dto.ts │ │ │ │ │ ├── timeline.dto.ts │ │ │ │ │ └── random.dto.ts │ │ │ ├── posts │ │ │ │ ├── posts.module.ts │ │ │ │ └── dto │ │ │ │ │ └── index.ts │ │ │ ├── recently │ │ │ │ ├── recently.dto.ts │ │ │ │ ├── recently.service.ts │ │ │ │ └── recently.controller.ts │ │ │ ├── analyze │ │ │ │ └── analyze.dto.ts │ │ │ ├── says │ │ │ │ ├── says.service.ts │ │ │ │ └── says.controller.ts │ │ │ ├── projects │ │ │ │ ├── projects.controller.ts │ │ │ │ ├── projects.service.ts │ │ │ │ └── dto │ │ │ │ │ └── project.dto.ts │ │ │ ├── page │ │ │ │ ├── page.service.ts │ │ │ │ └── page.controller.ts │ │ │ ├── helper │ │ │ │ └── dto │ │ │ │ │ └── datatype.dto.ts │ │ │ ├── links │ │ │ │ └── links.controller.ts │ │ │ ├── categories │ │ │ │ └── dto │ │ │ │ │ └── category.dto.ts │ │ │ ├── options │ │ │ │ ├── options.controller.ts │ │ │ │ └── options.service.ts │ │ │ └── notes │ │ │ │ └── dto │ │ │ │ └── note.dto.ts │ │ ├── auth │ │ │ ├── interfaces │ │ │ │ └── jwt-payload.interface.ts │ │ │ ├── oauth.dto.ts │ │ │ ├── jwt.strategy.ts │ │ │ ├── auth.module.ts │ │ │ ├── local.strategy.ts │ │ │ ├── roles.guard.ts │ │ │ └── auth.controller.ts │ │ ├── gateway │ │ │ ├── web │ │ │ │ ├── dtos │ │ │ │ │ └── danmaku.dto.ts │ │ │ │ └── events.gateway.ts │ │ │ ├── shared │ │ │ │ └── events.gateway.ts │ │ │ ├── gateway.module.ts │ │ │ ├── base.gateway.ts │ │ │ ├── events.types.ts │ │ │ └── admin │ │ │ │ └── events.gateway.ts │ │ ├── master │ │ │ ├── master.module.ts │ │ │ └── dto │ │ │ │ └── user.dto.ts │ │ ├── app.module.ts │ │ ├── main.ts │ │ ├── app.controller.ts │ │ └── plugins │ │ │ └── parseMarkdownYAML.ts │ └── tsconfig.app.json └── graphql │ ├── src │ ├── shared │ │ ├── base │ │ │ └── interfaces │ │ │ │ └── index.ts │ │ ├── posts │ │ │ ├── posts.input.ts │ │ │ ├── posts.service.spec.ts │ │ │ ├── posts.resolver.spec.ts │ │ │ ├── posts.service.ts │ │ │ └── posts.resolver.ts │ │ ├── notes │ │ │ ├── notes.service.spec.ts │ │ │ ├── notes.resolver.spec.ts │ │ │ ├── notes.input.ts │ │ │ └── notes.service.ts │ │ ├── pages │ │ │ ├── pages.service.spec.ts │ │ │ ├── pages.resolver.spec.ts │ │ │ ├── pages.service.ts │ │ │ └── pages.resolver.ts │ │ ├── aggregate │ │ │ ├── aggregate.service.spec.ts │ │ │ ├── aggregate.resolver.spec.ts │ │ │ ├── aggregate.input.ts │ │ │ ├── aggregate.resolver.ts │ │ │ └── aggregate.service.ts │ │ ├── categories │ │ │ ├── categories.resolver.spec.ts │ │ │ └── category.input.ts │ │ └── shared.module.ts │ ├── root.controller.ts │ ├── graphql │ │ ├── models │ │ │ ├── master.model.ts │ │ │ ├── category.model.ts │ │ │ ├── page.model.ts │ │ │ ├── aggregate.model.ts │ │ │ ├── post.model.ts │ │ │ ├── note.model.ts │ │ │ └── base.model.ts │ │ └── args │ │ │ └── id.input.ts │ ├── main.ts │ └── graphql.module.ts │ └── tsconfig.app.json ├── libs ├── common │ ├── src │ │ ├── index.ts │ │ ├── redis │ │ │ └── redis.types.ts │ │ ├── tasks │ │ │ └── tasks.module.ts │ │ └── common.module.ts │ └── tsconfig.lib.json └── db │ ├── src │ ├── index.ts │ ├── db.service.ts │ ├── models │ │ ├── recently.model.ts │ │ ├── option.model.ts │ │ ├── say.model.ts │ │ ├── analyze.model.ts │ │ ├── category.model.ts │ │ ├── danmaku.model.ts │ │ ├── post.model.ts │ │ ├── file.model.ts │ │ ├── link.model.ts │ │ ├── base.model.ts │ │ ├── user.model.ts │ │ ├── page.model.ts │ │ ├── project.model.ts │ │ ├── note.model.ts │ │ └── comment.model.ts │ └── db.module.ts │ └── tsconfig.lib.json ├── tsconfig.jest.json ├── .env.example ├── global.d.ts ├── .prettierrc ├── tsconfig.build.json ├── patch ├── v1.4.0.ts ├── v1.3.0.ts ├── v1.8.0.ts ├── v1.5.0.ts ├── tsconfg.json ├── v1.5.1.ts └── bootstrap.ts ├── .vscode ├── settings.json └── launch.json ├── create-tags.sh ├── CHANGELOG ├── shared ├── utils │ ├── validator-decorators │ │ ├── default.ts │ │ ├── isBooleanOrString.ts │ │ ├── isMongoIdOrInt.ts │ │ ├── isNilOrString.ts │ │ └── simpleValidatorFactory.ts │ ├── ip.ts │ ├── time.ts │ ├── pic.ts │ ├── text-base.ts │ └── index.ts ├── global │ ├── index.ts │ ├── configs │ │ └── configs.module.ts │ ├── tools │ │ └── tools.module.ts │ └── global.module.ts ├── core │ ├── dtos │ │ └── file.dto.ts │ ├── exceptions │ │ └── cant-find.exception.ts │ ├── middlewares │ │ ├── favicon.middleware.ts │ │ └── security.middleware.ts │ ├── decorators │ │ ├── current-user.decorator.ts │ │ ├── file.decorator.ts │ │ ├── auth.decorator.ts │ │ ├── ip.decorator.ts │ │ └── guest.decorator.ts │ ├── gateway │ │ └── extend.gateway.ts │ ├── adapt │ │ └── fastify.ts │ ├── interceptors │ │ ├── http-cache.interceptors.ts │ │ ├── permission.interceptors.ts │ │ ├── secret.interceptors.ts │ │ └── response.interceptors.ts │ ├── guards │ │ └── spider.guard.ts │ └── filters │ │ └── any-exception.filter.ts └── constants │ └── index.ts ├── release.sh ├── renovate.json ├── configs ├── proxy.conf ├── security.conf ├── general.conf └── nginx.conf ├── README.md ├── webpack-hmr.config.js ├── story.md ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── ecosystem.config.js ├── tsconfig.json ├── LICENSE ├── tools ├── represh-md-image-links.ts └── replace-cos-domain.ts ├── bin └── patch.js ├── .eslintrc.js └── nest-cli.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.spec.ts 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /apps/server/src/shared/uploads/dto/image.dto.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /libs/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common.module' 2 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/base/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export type AnyType = any 2 | -------------------------------------------------------------------------------- /apps/server/src/shared/base/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export type AnyType = any 2 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "baseUrl": ".", 4 | } -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | lint-staged 5 | -------------------------------------------------------------------------------- /libs/db/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './db.module' 2 | export * from './db.service' 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SECRET=ahsdljasdasdhaisASHLDasd 2 | PORT=2333 3 | APP_MAX_MEMORY=150M 4 | ORIGIN= 5 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | export interface IncomingMessage { 4 | originalUrl: string 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 2, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /libs/db/src/db.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class DbService {} 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/server/src/auth/interfaces/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayload { 2 | _id: string 3 | authCode: string 4 | } 5 | -------------------------------------------------------------------------------- /patch/v1.4.0.ts: -------------------------------------------------------------------------------- 1 | import { patch } from './bootstrap' 2 | 3 | patch(async ({ models: { analyze } }) => { 4 | await analyze.updateMany({}, { $unset: { modified: '' } }).exec() 5 | }) 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "DANMAKU", 4 | "Fastify", 5 | "Paginator", 6 | "dtos", 7 | "nestjs" 8 | ], 9 | "typescript.tsdk": "node_modules/typescript/lib" 10 | } -------------------------------------------------------------------------------- /create-tags.sh: -------------------------------------------------------------------------------- 1 | yarn version 2 | tag=v$(json -f package.json version) 3 | git add . 4 | git commit -a -m "release: $tag" &> /dev/null 5 | git push 6 | git tag -a "$tag" -m "Release $tag" &> /dev/null 7 | git push --tags -------------------------------------------------------------------------------- /apps/server/src/auth/oauth.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined, IsNotEmpty, IsString } from 'class-validator' 2 | 3 | export class OAuthVerifyQueryDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | @IsDefined() 7 | code: string 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | **v1.4.0** 4 | 5 | - feat: 支持显示标签云 6 | - refactor: analyze fragment query replace with mongo aggregate. 7 | 8 | **v1.5.0** 9 | 10 | - fix: 文章修改时间被评论影响 11 | - refactor: analyze 字段修改 12 | 13 | **v1.6.0** 14 | 15 | - feat: GraphQL support -------------------------------------------------------------------------------- /apps/server/src/shared/comments/dto/state.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsEnum, IsInt } from 'class-validator' 3 | 4 | export class StateDto { 5 | @IsInt() 6 | @IsEnum([0, 1, 2]) 7 | @ApiProperty() 8 | state: number 9 | } 10 | -------------------------------------------------------------------------------- /libs/db/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/db" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/graphql/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/graphql" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/server/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "outDir": "../../dist/apps/server" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/common/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "outDir": "../../dist/libs/common" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /patch/v1.3.0.ts: -------------------------------------------------------------------------------- 1 | // patch for version lower than v1.3.0 2 | 3 | import { CategoryType } from '../libs/db/src/models/category.model' 4 | import { patch } from './bootstrap' 5 | 6 | patch(async ({ models: { category } }) => { 7 | await category.deleteMany({ 8 | type: CategoryType.Tag, 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /shared/utils/validator-decorators/default.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | 3 | export function Default(defaultValue: any) { 4 | return Transform( 5 | (value: any) => 6 | value !== null && value !== undefined ? value : defaultValue, 7 | { toClassOnly: true }, 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /libs/db/src/models/recently.model.ts: -------------------------------------------------------------------------------- 1 | import { prop } from '@typegoose/typegoose' 2 | import { BaseModel } from './base.model' 3 | 4 | export class Recently extends BaseModel { 5 | @prop({ required: true }) 6 | content: string 7 | @prop() 8 | project?: string 9 | @prop() 10 | language?: string 11 | } 12 | -------------------------------------------------------------------------------- /apps/server/src/shared/aggregate/dtos/top.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer' 2 | import { IsOptional, Max, Min } from 'class-validator' 3 | 4 | export class TopQueryDto { 5 | @Transform(({ value: val }) => parseInt(val)) 6 | @Min(1) 7 | @Max(10) 8 | @IsOptional() 9 | size?: number 10 | } 11 | -------------------------------------------------------------------------------- /apps/server/src/shared/posts/posts.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { PostsController } from './posts.controller' 3 | import { PostsService } from './posts.service' 4 | 5 | @Module({ 6 | controllers: [PostsController], 7 | providers: [PostsService], 8 | }) 9 | export class PostsModule {} 10 | -------------------------------------------------------------------------------- /shared/global/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-09-09 13:40:23 4 | * @LastEditTime: 2020-09-09 13:40:49 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/common/global/index.ts 7 | * @Mark: Coding with Love 8 | */ 9 | export * from './configs/configs.service' 10 | export * from './tools/tools.service' 11 | -------------------------------------------------------------------------------- /apps/server/src/shared/comments/comments.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { CommentsController } from './comments.controller' 3 | import { CommentsService } from './comments.service' 4 | 5 | @Module({ 6 | controllers: [CommentsController], 7 | providers: [CommentsService], 8 | }) 9 | export class CommentsModule {} 10 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/posts/posts.input.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field } from '@nestjs/graphql' 2 | import { IsNotEmpty, IsString } from 'class-validator' 3 | 4 | @ArgsType() 5 | export class SlugTitleInput { 6 | @IsString() 7 | @IsNotEmpty() 8 | @Field() 9 | category: string 10 | 11 | @IsString() 12 | @IsNotEmpty() 13 | @Field() 14 | slug: string 15 | } 16 | -------------------------------------------------------------------------------- /libs/db/src/models/option.model.ts: -------------------------------------------------------------------------------- 1 | import { prop, modelOptions, Severity } from '@typegoose/typegoose' 2 | import { Schema } from 'mongoose' 3 | 4 | @modelOptions({ 5 | options: { allowMixed: Severity.ALLOW }, 6 | }) 7 | export class Option { 8 | @prop({ unique: true, required: true }) 9 | name: string 10 | 11 | @prop({ type: Schema.Types.Mixed }) 12 | value: any 13 | } 14 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | ### 3 | # @Author: Innei 4 | # @Date: 2020-09-17 14:04:22 5 | # @LastEditTime: 2021-01-15 14:51:43 6 | # @LastEditors: Innei 7 | # @FilePath: /server/release.sh 8 | # @Mark: Coding with Love 9 | ### 10 | yarn 11 | yarn build:server 12 | yarn add @zeit/ncc 13 | yarn run ncc build dist/apps/server/main.js -o release 14 | cp .env.example release/.env 15 | zip -r release.zip release/* release/.env 16 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":automergeDigest", 5 | ":automergePatch", 6 | ":automergeTypes", 7 | ":automergeTesters", 8 | ":automergeLinters", 9 | ":rebaseStalePrs" 10 | ], 11 | "packageRules": [ 12 | { 13 | "updateTypes": [ 14 | "major" 15 | ], 16 | "labels": [ 17 | "UPDATE-MAJOR" 18 | ] 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /shared/core/dtos/file.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | 3 | /* 4 | * @Author: Innei 5 | * @Date: 2020-07-31 20:22:01 6 | * @LastEditTime: 2020-07-31 20:22:01 7 | * @LastEditors: Innei 8 | * @FilePath: /mx-server/src/core/dtos/file.dto.ts 9 | * @Coding with Love 10 | */ 11 | export class FileUploadDto { 12 | @ApiProperty({ type: 'string', format: 'binary' }) 13 | file: any 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Debug API", 11 | "sourceMaps": true, 12 | "port": 9229 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /apps/graphql/src/root.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2021-01-15 13:44:56 4 | * @LastEditTime: 2021-01-15 13:47:37 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/graphql/src/root.controller.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { Controller, Get } from '@nestjs/common' 10 | 11 | @Controller() 12 | export class RootController { 13 | @Get('ping') 14 | getHello(): string { 15 | return 'pong' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/server/src/shared/uploads/dto/filetype.dto.ts: -------------------------------------------------------------------------------- 1 | import { FileType } from '@libs/db/models/file.model' 2 | import { ApiProperty } from '@nestjs/swagger' 3 | import { Transform } from 'class-transformer' 4 | import { IsEnum, IsOptional } from 'class-validator' 5 | 6 | export class FileTypeQueryDto { 7 | @Transform(({ value: val }) => Number(val)) 8 | @IsOptional() 9 | @IsEnum(FileType) 10 | @ApiProperty({ enum: Object.keys(FileType) }) 11 | type?: FileType 12 | } 13 | -------------------------------------------------------------------------------- /configs/proxy.conf: -------------------------------------------------------------------------------- 1 | proxy_http_version→ 1.1 2 | proxy_cache_bypass→ $http_upgrade 3 | 4 | proxy_set_header Upgrade→ → → $http_upgrade 5 | proxy_set_header Connection → → "upgrade" 6 | proxy_set_header Host→ → → → $host 7 | proxy_set_header X-Real-IP→ → → $remote_addr 8 | proxy_set_header X-Forwarded-For→ $proxy_add_x_forwarded_for 9 | proxy_set_header X-Forwarded-Proto→ $scheme 10 | proxy_set_header X-Forwarded-Host→ $host 11 | proxy_set_header X-Forwarded-Port→ $server_port 12 | -------------------------------------------------------------------------------- /patch/v1.8.0.ts: -------------------------------------------------------------------------------- 1 | import { patch } from './bootstrap' 2 | 3 | /* 4 | * @Author: Innei 5 | * @Date: 2021-01-15 14:18:33 6 | * @LastEditTime: 2021-01-15 14:21:26 7 | * @LastEditors: Innei 8 | * @FilePath: /server/patch/v1.8.0.ts 9 | * @Mark: Coding with Love 10 | */ 11 | patch(async ({ db }) => { 12 | await db.collection('options').updateOne( 13 | { name: 'commentOptions' }, 14 | { 15 | $unset: { 'value.akismetApiKey': 1 }, 16 | }, 17 | ) 18 | }) 19 | -------------------------------------------------------------------------------- /shared/core/exceptions/cant-find.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common' 2 | import { PickOne } from 'shared/utils' 3 | 4 | export const NotFoundMessage = [ 5 | '真不巧, 内容走丢了 o(╥﹏╥)o', 6 | '电波无法到达 ωω', 7 | '数据..不小心丢失了啦 π_π', 8 | '404, 这也不是我的错啦 (๐•̆ ·̭ •̆๐)', 9 | '嘿, 这里空空如也, 不如别处走走?', 10 | ] 11 | 12 | export class CannotFindException extends NotFoundException { 13 | constructor() { 14 | super(PickOne(NotFoundMessage)) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /libs/db/src/models/say.model.ts: -------------------------------------------------------------------------------- 1 | import { BaseModel } from '@libs/db/models/base.model' 2 | import { prop } from '@typegoose/typegoose' 3 | import { IsOptional, IsString } from 'class-validator' 4 | 5 | export class Say extends BaseModel { 6 | @prop({ required: true }) 7 | @IsString() 8 | text: string 9 | 10 | @prop() 11 | @IsString() 12 | @IsOptional() 13 | source: string 14 | 15 | @prop() 16 | @IsString() 17 | @IsOptional() 18 | author: string 19 | } 20 | -------------------------------------------------------------------------------- /apps/server/src/shared/recently/recently.dto.ts: -------------------------------------------------------------------------------- 1 | import { Recently } from '@libs/db/models/recently.model' 2 | import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator' 3 | 4 | export class RecentlyDto implements Recently { 5 | @IsString() 6 | @IsNotEmpty() 7 | content: string 8 | @IsString() 9 | @IsOptional() 10 | @IsNotEmpty() 11 | @MaxLength(100) 12 | project?: string 13 | @IsString() 14 | @IsOptional() 15 | @IsNotEmpty() 16 | language?: string 17 | } 18 | -------------------------------------------------------------------------------- /shared/global/configs/configs.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-08 17:02:08 4 | * @LastEditTime: 2020-09-09 13:36:59 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/common/global/configs/configs.module.ts 7 | * @Copyright 8 | */ 9 | 10 | import { Module } from '@nestjs/common' 11 | import { ConfigsService } from './configs.service' 12 | 13 | @Module({ 14 | providers: [ConfigsService], 15 | exports: [ConfigsService], 16 | }) 17 | export class ConfigsModule {} 18 | -------------------------------------------------------------------------------- /apps/server/src/gateway/web/dtos/danmaku.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsString, 3 | IsOptional, 4 | IsInt, 5 | IsNotEmpty, 6 | IsHexColor, 7 | MaxLength, 8 | } from 'class-validator' 9 | export class DanmakuDto { 10 | @IsString() 11 | @IsNotEmpty() 12 | author: string 13 | @IsOptional() 14 | @IsHexColor() 15 | color?: string 16 | 17 | @IsInt() 18 | @IsOptional() 19 | duration?: number 20 | 21 | @IsString() 22 | @IsNotEmpty() 23 | @MaxLength(50, { message: '长度不能超过50个字符' }) 24 | text: string 25 | } 26 | -------------------------------------------------------------------------------- /configs/security.conf: -------------------------------------------------------------------------------- 1 | # security headers 2 | add_header X-Frame-Options "SAMEORIGIN" always; 3 | add_header X-XSS-Protection "1; mode=block" always; 4 | add_header X-Content-Type-Options "nosniff" always; 5 | add_header Referrer-Policy "no-referrer-when-downgrade" always; 6 | add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; 7 | add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; 8 | 9 | # . files 10 | location ~ /\.(?!well-known) { 11 | deny all; 12 | } -------------------------------------------------------------------------------- /apps/server/src/shared/analyze/analyze.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Transform } from 'class-transformer' 3 | import { IsDate, IsOptional } from 'class-validator' 4 | 5 | export class AnalyzeDto { 6 | @Transform(({ value: v }) => new Date(parseInt(v))) 7 | @IsOptional() 8 | @IsDate() 9 | @ApiProperty({ type: 'string' }) 10 | from?: Date 11 | 12 | @Transform(({ value: v }) => new Date(parseInt(v))) 13 | @IsOptional() 14 | @IsDate() 15 | @ApiProperty({ type: 'string' }) 16 | to?: Date 17 | } 18 | -------------------------------------------------------------------------------- /libs/common/src/redis/redis.types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-06-03 11:50:17 4 | * @LastEditTime: 2020-06-03 11:50:18 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/libs/common/src/redis/redis.types.ts 7 | * @Coding with Love 8 | */ 9 | export enum RedisNames { 10 | Access = 'access', 11 | Like = 'like', 12 | Read = 'read', 13 | LoginRecord = 'login_record', 14 | MaxOnlineCount = 'max_online_count', 15 | // LikeThisSite = 'like_this_site', 16 | } 17 | export enum RedisItems { 18 | Ips = 'ips', 19 | } 20 | -------------------------------------------------------------------------------- /apps/server/src/shared/says/says.service.ts: -------------------------------------------------------------------------------- 1 | import { Say } from '@libs/db/models/say.model' 2 | import { Injectable } from '@nestjs/common' 3 | import { ReturnModelType } from '@typegoose/typegoose' 4 | import { InjectModel } from 'nestjs-typegoose' 5 | import { BaseService } from 'apps/server/src/shared/base/base.service' 6 | 7 | @Injectable() 8 | export class SaysService extends BaseService { 9 | constructor( 10 | @InjectModel(Say) 11 | private readonly Model: ReturnModelType, 12 | ) { 13 | super(Model) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/server/src/shared/projects/projects.controller.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@libs/db/models/project.model' 2 | import { Controller } from '@nestjs/common' 3 | import { ApiTags } from '@nestjs/swagger' 4 | import { BaseCrud } from '../base/base.controller' 5 | import { ProjectsService } from './projects.service' 6 | 7 | @Controller('projects') 8 | @ApiTags('Project Routes') 9 | export class ProjectsController extends BaseCrud { 10 | constructor(private readonly projectService: ProjectsService) { 11 | super(projectService) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/notes/notes.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { NotesService } from './notes.service'; 3 | 4 | describe('NotesService', () => { 5 | let service: NotesService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [NotesService], 10 | }).compile(); 11 | 12 | service = module.get(NotesService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/pages/pages.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PagesService } from './pages.service'; 3 | 4 | describe('PagesService', () => { 5 | let service: PagesService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [PagesService], 10 | }).compile(); 11 | 12 | service = module.get(PagesService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/posts/posts.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PostsService } from './posts.service'; 3 | 4 | describe('PostsService', () => { 5 | let service: PostsService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [PostsService], 10 | }).compile(); 11 | 12 | service = module.get(PostsService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /shared/core/middlewares/favicon.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common' 2 | import { IncomingMessage, ServerResponse } from 'http' 3 | 4 | @Injectable() 5 | export class SkipBrowserDefaultRequestMiddleware implements NestMiddleware { 6 | async use(req: IncomingMessage, res: ServerResponse, next: () => void) { 7 | // @ts-ignore 8 | const url = req.originalUrl 9 | 10 | if (url.match(/favicon.ico$/) || url.match(/manifest.json$/)) { 11 | res.writeHead(204) 12 | return res.end() 13 | } 14 | next() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /libs/common/src/tasks/tasks.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-08 17:02:08 4 | * @LastEditTime: 2020-08-24 22:20:24 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/libs/common/src/tasks/tasks.module.ts 7 | * @Coding with Love 8 | */ 9 | import { Module } from '@nestjs/common' 10 | import { ScheduleModule } from '@nestjs/schedule' 11 | import { TasksService } from './tasks.service' 12 | 13 | @Module({ 14 | imports: [ScheduleModule.forRoot()], 15 | providers: [TasksService], 16 | exports: [TasksService], 17 | }) 18 | export class TasksModule {} 19 | -------------------------------------------------------------------------------- /shared/core/decorators/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 2 | import { GqlExecutionContext } from '@nestjs/graphql' 3 | 4 | export const CurrentUser = createParamDecorator( 5 | (data: unknown, ctx: ExecutionContext) => { 6 | return ctx.switchToHttp().getRequest().user 7 | }, 8 | ) 9 | 10 | export const CurrentUserGQL = createParamDecorator( 11 | (data: unknown, context: ExecutionContext) => { 12 | const ctx = GqlExecutionContext.create(context) 13 | return ctx.getContext().req.user 14 | }, 15 | ) 16 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/notes/notes.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { NotesResolver } from './notes.resolver'; 3 | 4 | describe('NotesResolver', () => { 5 | let resolver: NotesResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [NotesResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(NotesResolver); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/pages/pages.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PagesResolver } from './pages.resolver'; 3 | 4 | describe('PagesResolver', () => { 5 | let resolver: PagesResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [PagesResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(PagesResolver); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/posts/posts.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PostsResolver } from './posts.resolver'; 3 | 4 | describe('PostsResolver', () => { 5 | let resolver: PostsResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [PostsResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(PostsResolver); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /shared/core/gateway/extend.gateway.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-06 11:57:22 4 | * @LastEditTime: 2020-08-03 10:53:02 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/core/gateway/extend.gateway.ts 7 | * @Coding with Love 8 | */ 9 | import { IoAdapter } from '@nestjs/platform-socket.io' 10 | import type { Server } from 'socket.io' 11 | 12 | export class ExtendsIoAdapter extends IoAdapter { 13 | createIOServer(port: number, options?: any): any { 14 | const server = super.createIOServer(port, options) as Server 15 | 16 | return server 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /shared/global/tools/tools.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-08-24 21:34:13 4 | * @LastEditTime: 2020-09-09 13:38:19 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/common/global/tools/tools.module.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { Module } from '@nestjs/common' 10 | import { GatewayModule } from 'apps/server/src/gateway/gateway.module' 11 | import { ToolsService } from './tools.service' 12 | 13 | @Module({ 14 | providers: [ToolsService], 15 | exports: [ToolsService], 16 | imports: [GatewayModule], 17 | }) 18 | export class ToolsModule {} 19 | -------------------------------------------------------------------------------- /apps/server/src/shared/projects/projects.service.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@libs/db/models/project.model' 2 | import { Injectable } from '@nestjs/common' 3 | import { ReturnModelType } from '@typegoose/typegoose' 4 | import { InjectModel } from 'nestjs-typegoose' 5 | import { BaseService } from 'apps/server/src/shared/base/base.service' 6 | 7 | @Injectable() 8 | export class ProjectsService extends BaseService { 9 | constructor( 10 | @InjectModel(Project) 11 | private readonly projectModel: ReturnModelType, 12 | ) { 13 | super(projectModel) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /libs/db/src/models/analyze.model.ts: -------------------------------------------------------------------------------- 1 | import { modelOptions, prop } from '@typegoose/typegoose' 2 | import { SchemaTypes } from 'mongoose' 3 | import { BaseModel } from './base.model' 4 | import { UAParser } from 'ua-parser-js' 5 | @modelOptions({ 6 | schemaOptions: { 7 | timestamps: { 8 | createdAt: 'timestamp', 9 | updatedAt: false, 10 | }, 11 | }, 12 | }) 13 | export class Analyze extends BaseModel { 14 | @prop() 15 | ip?: string 16 | 17 | @prop({ type: SchemaTypes.Mixed }) 18 | ua: UAParser 19 | 20 | @prop() 21 | path?: string 22 | 23 | timestamp: Date 24 | } 25 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/aggregate/aggregate.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AggregateService } from './aggregate.service'; 3 | 4 | describe('AggregateService', () => { 5 | let service: AggregateService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AggregateService], 10 | }).compile(); 11 | 12 | service = module.get(AggregateService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mix Space Server 2 | 3 | [![DeepScan grade](https://deepscan.io/api/teams/7938/projects/10675/branches/150239/badge/grade.svg)](https://deepscan.io/dashboard#view=project&tid=7938&pid=10675&bid=150239) 4 | [![time tracker](https://wakatime.com/badge/github/mx-space/server.svg)](https://wakatime.com/badge/github/mx-space/server) 5 | 6 | ## 技术栈 7 | 8 | - NestJS 9 | - MongoDB 10 | - Typegoose 11 | - WebSocket (Socket.IO) 12 | - Redis 13 | - GraphQL 14 | 15 | ## 注意 16 | 17 | 第一次使用会自动建立用户 18 | 19 | 密码为: master 20 | 21 | 可以进入后台管理之后自行修改. 22 | 23 | ## 补丁 24 | 25 | 在每次升级之后可能需要打补丁, 使用 `yarn patch` 可以选择可用的补丁 26 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/aggregate/aggregate.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AggregateResolver } from './aggregate.resolver'; 3 | 4 | describe('AggregateResolver', () => { 5 | let resolver: AggregateResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AggregateResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(AggregateResolver); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/server/src/gateway/shared/events.gateway.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { AdminEventsGateway } from '../admin/events.gateway' 3 | import { EventTypes } from '../events.types' 4 | import { WebEventsGateway } from '../web/events.gateway' 5 | 6 | @Injectable() 7 | export class SharedGateway { 8 | constructor( 9 | private readonly admin: AdminEventsGateway, 10 | private readonly web: WebEventsGateway, 11 | ) {} 12 | 13 | async broadcase(event: EventTypes, data: any) { 14 | await this.admin.broadcast(event, data) 15 | await this.web.broadcast(event, data) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /configs/general.conf: -------------------------------------------------------------------------------- 1 | # favicon.ico 2 | location = /favicon.ico { 3 | log_not_found off; 4 | access_log off; 5 | return 204 6 | } 7 | 8 | # robots.txt 9 | location = /robots.txt { 10 | log_not_found off; 11 | access_log off; 12 | } 13 | 14 | # assets, media 15 | location ~* \.(?:css(\.map)?|js(\.map)?|jpe?g|png|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv)$ { 16 | expires 7d; 17 | access_log off; 18 | } 19 | 20 | # svg, fonts 21 | location ~* \.(?:svgz?|ttf|ttc|otf|eot|woff2?)$ { 22 | add_header Access-Control-Allow-Origin "*"; 23 | expires 7d; 24 | access_log off; 25 | } -------------------------------------------------------------------------------- /apps/graphql/src/shared/categories/categories.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CategoriesResolver } from './categories.resolver'; 3 | 4 | describe('CategoriesResolver', () => { 5 | let resolver: CategoriesResolver; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [CategoriesResolver], 10 | }).compile(); 11 | 12 | resolver = module.get(CategoriesResolver); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(resolver).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /apps/server/src/shared/base/dto/id.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsMongoId } from 'class-validator' 3 | import { IsBooleanOrString } from 'utils/validator-decorators/isBooleanOrString' 4 | 5 | export class MongoIdDto { 6 | @IsMongoId() 7 | @ApiProperty({ 8 | name: 'id', 9 | // enum: ['5e6f67e75b303781d2807279', '5e6f67e75b303781d280727f'], 10 | example: '5e6f67e75b303781d2807278', 11 | }) 12 | id: string 13 | } 14 | 15 | export class IntIdOrMongoIdDto { 16 | @IsBooleanOrString() 17 | @ApiProperty({ example: ['12', '5e6f67e75b303781d2807278'] }) 18 | id: string | number 19 | } 20 | -------------------------------------------------------------------------------- /libs/db/src/models/category.model.ts: -------------------------------------------------------------------------------- 1 | import { DocumentType, index, prop } from '@typegoose/typegoose' 2 | import { BaseModel } from './base.model' 3 | export type CategoryDocument = DocumentType 4 | 5 | export enum CategoryType { 6 | Category, 7 | Tag, 8 | } 9 | 10 | @index({ count: -1 }) 11 | @index({ slug: -1 }) 12 | export default class Category extends BaseModel { 13 | @prop({ unique: true, trim: true, required: true }) 14 | name!: string 15 | 16 | @prop({ default: CategoryType.Category }) 17 | type?: CategoryType 18 | 19 | @prop({ unique: true, required: true }) 20 | slug!: string 21 | 22 | @prop({ default: 0 }) 23 | count?: number 24 | } 25 | -------------------------------------------------------------------------------- /apps/server/src/shared/uploads/uploads.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 16:03:37 4 | * @LastEditTime: 2020-07-31 20:09:19 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/shared/uploads/uploads.module.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { Module } from '@nestjs/common' 11 | import { ImageService } from './image.service' 12 | import { UploadsController } from './uploads.controller' 13 | import { UploadsService } from './uploads.service' 14 | 15 | @Module({ 16 | providers: [ImageService, UploadsService], 17 | controllers: [UploadsController], 18 | exports: [ImageService, UploadsService], 19 | }) 20 | export class UploadsModule {} 21 | -------------------------------------------------------------------------------- /patch/v1.5.0.ts: -------------------------------------------------------------------------------- 1 | import { patch } from './bootstrap' 2 | 3 | patch(async ({ db }) => { 4 | await Promise.all( 5 | ['comments', 'links', 'says', 'projects', 'categories'].map( 6 | async (collection) => { 7 | return await db.collection(collection).updateMany( 8 | {}, 9 | { 10 | $unset: { 11 | modified: '', 12 | }, 13 | }, 14 | ) 15 | }, 16 | ), 17 | ) 18 | 19 | await db.collection('analyzes').updateMany( 20 | {}, 21 | { 22 | $unset: { 23 | modified: '', 24 | }, 25 | $rename: { 26 | created: 'timestamp', 27 | }, 28 | }, 29 | ) 30 | }) 31 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql/models/master.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-02 21:16:33 4 | * @LastEditTime: 2020-10-02 21:33:53 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/graphql/models/master.model.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { Field, ID, ObjectType } from '@nestjs/graphql' 10 | import { BaseGLModel } from './base.model' 11 | 12 | @ObjectType() 13 | export class MasterModel extends BaseGLModel { 14 | token: string 15 | 16 | name: string 17 | username: string 18 | created: Date 19 | url?: string 20 | mail?: string 21 | 22 | avatar?: string 23 | expiresIn: number 24 | 25 | @Field(() => ID) 26 | _id: string 27 | } 28 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/aggregate/aggregate.input.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-04 09:15:23 4 | * @LastEditTime: 2020-10-04 09:19:55 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/aggregate/aggregate.input.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { ArgsType, Field, Int } from '@nestjs/graphql' 10 | import { IsEnum, IsInt, IsOptional } from 'class-validator' 11 | import { SortOrder } from '../../graphql/args/id.input' 12 | 13 | @ArgsType() 14 | export class TimelineArgsDto { 15 | @IsEnum(SortOrder) 16 | @Field(() => Int, { defaultValue: 1 }) 17 | @IsOptional() 18 | sort?: number 19 | 20 | @IsInt() 21 | @IsOptional() 22 | year?: number 23 | } 24 | -------------------------------------------------------------------------------- /apps/graphql/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common' 2 | import { NestFactory } from '@nestjs/core' 3 | import { NestExpressApplication } from '@nestjs/platform-express' 4 | import { isDev } from 'shared/utils' 5 | import { GraphqlModule } from './graphql.module' 6 | 7 | const PORT = 2331 8 | async function bootstrap() { 9 | const app = await NestFactory.create(GraphqlModule) 10 | 11 | await app.listen(PORT, async () => { 12 | if (isDev) { 13 | Logger.debug('Server listen on ' + `http://localhost:${PORT}`) 14 | Logger.debug( 15 | 'GraphQL playground listen on ' + `http://localhost:${PORT}/graphql`, 16 | ) 17 | } 18 | }) 19 | } 20 | bootstrap() 21 | -------------------------------------------------------------------------------- /webpack-hmr.config.js: -------------------------------------------------------------------------------- 1 | const nodeExternals = require('webpack-node-externals') 2 | const { RunScriptWebpackPlugin } = require('run-script-webpack-plugin') 3 | 4 | module.exports = function (options, webpack) { 5 | return { 6 | ...options, 7 | entry: ['webpack/hot/poll?100', options.entry], 8 | externals: [ 9 | nodeExternals({ 10 | allowlist: ['webpack/hot/poll?100'], 11 | }), 12 | ], 13 | plugins: [ 14 | ...options.plugins, 15 | new webpack.HotModuleReplacementPlugin(), 16 | new webpack.WatchIgnorePlugin({ 17 | paths: [/\.js$/, /\.d\.ts$/], 18 | }), 19 | new RunScriptWebpackPlugin({ name: options.output.filename }), 20 | ], 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /story.md: -------------------------------------------------------------------------------- 1 | # 开发故事 2 | 3 | **前篇 → https://innei.ren/notes/58** 4 | 5 | 在我写这篇文章时,我已经来到了学校,不得不说这次假期格外的长。也正因为如此,我才能有足够多的时间去完成这个项目。终于在返校前夕,我完成了所有的基本功能,并且已经部署上线了。虽然还有很多不足,但这也需要后期花时间不断的去完善。 6 | 7 | 我的开发初衷是自己想把两个博客融合。也希望能通过这个项目学习到很多的知识。当然我更希望更多人能看到这个项目,能使用它。毕竟它积淀了我 200 小时(来自 wakatime 的统计结果)的心血。 8 | 9 | 如今看来我已经完成了一半的目标。我知道越往后越难。知道的越多,不知道的更多。 10 | 11 | 通过这个项目,我从零学习了 NestJS(后端),NextJS(前端)。以及巩固了 Vue(中后台) 2 的知识。 12 | 13 | 收获颇多。我也十分欣慰。想起刚开始接触 NestJS 那时,复杂的依赖注入,模块分离,各种依赖注入导致的报错,让我几经想要放弃。但是经过一段时间的摸索,我才发现 NestJS 这套架构的稳健型,十分清晰的结构。NestJS 是参考了 Angular 的结构,所以我想着接下来去学学 Angular。(大概 14 | 15 | 接下来一段时间,工作学习之余,有时间写写 feature,修修 bug。还有时间的话,那就在记录一下开发历程,分享一下经验。 16 | 17 | 我想起在开发之始,在使用 Typegoose 架结构的时候,有一个[项目](https://github.com/jiayisheji/nest-cnode)帮助了我。在此感谢。 18 | -------------------------------------------------------------------------------- /shared/utils/ip.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-10 15:31:44 4 | * @LastEditTime: 2020-07-08 21:42:06 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/utils/ip.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { FastifyRequest } from 'fastify' 11 | import { IncomingMessage } from 'http' 12 | 13 | export const getIp = (request: FastifyRequest | IncomingMessage) => { 14 | const _ = request as any 15 | // @ts-ignore 16 | let ip: string = 17 | _.headers['x-forwarded-for'] || 18 | _.ip || 19 | _.raw.connection.remoteAddress || 20 | _.raw.socket.remoteAddress || 21 | undefined 22 | if (ip && ip.split(',').length > 0) { 23 | ip = ip.split(',')[0] 24 | } 25 | return ip 26 | } 27 | -------------------------------------------------------------------------------- /shared/core/decorators/file.decorator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-07-31 20:19:21 4 | * @LastEditTime: 2020-07-31 20:25:38 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/core/decorators/file.decorator.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { applyDecorators } from '@nestjs/common' 11 | import { ApiBody, ApiConsumes } from '@nestjs/swagger' 12 | import { FileUploadDto } from '../dtos/file.dto' 13 | 14 | export declare interface FileDecoratorProps { 15 | description: string 16 | } 17 | export function ApplyUpload({ description }: FileDecoratorProps) { 18 | return applyDecorators( 19 | ApiConsumes('multipart/form-data'), 20 | ApiBody({ 21 | description, 22 | type: FileUploadDto, 23 | }), 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /shared/utils/validator-decorators/isBooleanOrString.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-08-02 12:53:38 4 | * @LastEditTime: 2020-08-02 13:17:33 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/common/decorators/isBooleanOrString.ts 7 | * @Coding with Love 8 | */ 9 | import { isString, ValidationOptions } from 'class-validator' 10 | import { isBoolean, merge } from 'lodash' 11 | import { validatorFactory } from './simpleValidatorFactory' 12 | 13 | export function IsBooleanOrString(validationOptions?: ValidationOptions) { 14 | return validatorFactory((value) => isBoolean(value) || isString(value))( 15 | merge(validationOptions || {}, { 16 | message: '类型必须为 String or Boolean', 17 | }), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /shared/constants/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-08-01 19:49:31 4 | * @LastEditTime: 2021-03-21 19:36:20 5 | * @LastEditors: Innei 6 | * @FilePath: /server/shared/constants/index.ts 7 | * @Coding with Love 8 | */ 9 | import { homedir } from 'os' 10 | import { join } from 'path' 11 | import { isDev } from 'shared/utils' 12 | 13 | export const HOME = homedir() 14 | 15 | export const TEMP_DIR = isDev ? join(__dirname, '../tmp') : '/tmp/mx-space' 16 | 17 | export const DATA_DIR = isDev 18 | ? join(__dirname, '../tmp') 19 | : join(HOME, '.mx-space') 20 | 21 | export enum CacheKeys { 22 | AggregateCatch = 'aggregate_catch', 23 | SiteMapCatch = 'aggregate_sitemap_catch', 24 | RSS = 'rss', 25 | } 26 | 27 | export const CACHE_KEY_PREFIX = 'mx_cache:' 28 | -------------------------------------------------------------------------------- /shared/utils/validator-decorators/isMongoIdOrInt.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2021-02-04 15:17:04 4 | * @LastEditTime: 2021-02-04 15:18:08 5 | * @LastEditors: Innei 6 | * @FilePath: /server/shared/utils/validator-decorators/isMongoIdOrInt.ts 7 | * @Mark: Coding with Love 8 | */ 9 | 10 | import { isInt, isMongoId, ValidationOptions } from 'class-validator' 11 | import { merge } from 'lodash' 12 | import { validatorFactory } from './simpleValidatorFactory' 13 | 14 | export function IsBooleanOrString(validationOptions?: ValidationOptions) { 15 | return validatorFactory((value) => isInt(value) || isMongoId(value))( 16 | merge(validationOptions || {}, { 17 | message: '类型必须为 MongoId or Int', 18 | }), 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /shared/core/decorators/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-07-31 20:05:08 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/core/decorators/auth.decorator.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { applyDecorators, UseGuards } from '@nestjs/common' 11 | import { AuthGuard } from '@nestjs/passport' 12 | import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger' 13 | import { isDev } from 'shared/utils' 14 | 15 | export function Auth() { 16 | const decorators = [] 17 | if (!isDev) { 18 | decorators.push(UseGuards(AuthGuard('jwt'))) 19 | } 20 | decorators.push( 21 | ApiBearerAuth(), 22 | ApiUnauthorizedResponse({ description: 'Unauthorized' }), 23 | ) 24 | return applyDecorators(...decorators) 25 | } 26 | -------------------------------------------------------------------------------- /apps/server/src/gateway/gateway.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-05-31 19:07:17 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/gateway/gateway.module.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { Module } from '@nestjs/common' 11 | import { RedisModule } from 'nestjs-redis' 12 | import { AuthModule } from '../auth/auth.module' 13 | import { AdminEventsGateway } from './admin/events.gateway' 14 | import { SharedGateway } from './shared/events.gateway' 15 | import { WebEventsGateway } from './web/events.gateway' 16 | 17 | @Module({ 18 | imports: [AuthModule, RedisModule], 19 | providers: [AdminEventsGateway, WebEventsGateway, SharedGateway], 20 | exports: [AdminEventsGateway, WebEventsGateway, SharedGateway], 21 | }) 22 | export class GatewayModule {} 23 | -------------------------------------------------------------------------------- /shared/core/decorators/ip.decorator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-07-08 21:34:18 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/core/decorators/ip.decorator.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 11 | import { FastifyRequest } from 'fastify' 12 | import { getIp } from '../../utils/ip' 13 | 14 | export type IpRecord = { 15 | ip: string 16 | agent: string 17 | } 18 | export const IpLocation = createParamDecorator( 19 | (data: unknown, ctx: ExecutionContext) => { 20 | const request = ctx.switchToHttp().getRequest() 21 | const ip = getIp(request) 22 | const agent = request.headers['user-agent'] 23 | return { 24 | ip, 25 | agent, 26 | } 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /patch/tsconfg.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "../", 12 | "incremental": true, 13 | "resolveJsonModule": true, 14 | "lib": [ 15 | "ES2019", 16 | "ES2020.String" 17 | ], 18 | "paths": { 19 | "@libs/db": [ 20 | "libs/db/src" 21 | ], 22 | "@libs/db/*": [ 23 | "libs/db/src/*" 24 | ], 25 | "@libs/common": [ 26 | "libs/common/src" 27 | ], 28 | "@libs/common/*": [ 29 | "libs/common/src/*" 30 | ] 31 | } 32 | }, 33 | "exclude": [ 34 | "node_modules", 35 | "dist" 36 | ] 37 | } -------------------------------------------------------------------------------- /shared/global/global.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-09-09 13:37:11 4 | * @LastEditTime: 2020-09-09 13:38:06 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/common/global/global.module.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { Global, HttpModule, Module } from '@nestjs/common' 10 | import { ConfigsModule } from './configs/configs.module' 11 | import { ToolsModule } from './tools/tools.module' 12 | 13 | const Http = HttpModule.register({ 14 | timeout: 30000, 15 | headers: { 16 | 'User-Agent': 17 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36', 18 | }, 19 | }) 20 | @Global() 21 | @Module({ 22 | imports: [ToolsModule, ConfigsModule, Http], 23 | exports: [ToolsModule, ConfigsModule, Http], 24 | }) 25 | export class GlobalModule {} 26 | -------------------------------------------------------------------------------- /libs/db/src/models/danmaku.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-23 20:00:38 4 | * @LastEditTime: 2020-05-23 20:05:00 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/libs/db/src/models/danmaku.model.ts 7 | * @MIT 8 | */ 9 | 10 | import { prop } from '@typegoose/typegoose' 11 | import { 12 | IsString, 13 | IsNotEmpty, 14 | IsInt, 15 | Min, 16 | IsOptional, 17 | IsRgbColor, 18 | } from 'class-validator' 19 | 20 | export class Danmaku { 21 | @prop() 22 | @IsString() 23 | @IsNotEmpty() 24 | content: string 25 | 26 | @prop() 27 | @IsString() 28 | @IsNotEmpty() 29 | path: string 30 | 31 | @prop() 32 | @IsInt() 33 | @Min(5) 34 | duringTime: number 35 | 36 | @prop() 37 | @IsOptional() 38 | @IsRgbColor() 39 | color?: string 40 | 41 | @prop() 42 | @IsInt() 43 | @Min(0) 44 | startTime?: number 45 | } 46 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/notes/notes.input.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-02 14:45:58 4 | * @LastEditTime: 2020-10-02 15:48:38 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/notes/notes.input.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { ArgsType, Field, ID, Int } from '@nestjs/graphql' 10 | import { Transform } from 'class-transformer' 11 | import { IsInt, IsMongoId, IsOptional, IsString, Min } from 'class-validator' 12 | 13 | @ArgsType() 14 | export class NidOrIdArgsDto { 15 | @IsInt() 16 | @Field(() => Int) 17 | @Min(1) 18 | @IsOptional() 19 | @Transform(({ value: v }) => v | 0) 20 | nid?: number 21 | 22 | @Field(() => ID) 23 | @IsMongoId() 24 | @IsOptional() 25 | id?: string 26 | } 27 | 28 | @ArgsType() 29 | export class PasswordArgsDto { 30 | @IsString() 31 | @IsOptional() 32 | password?: string 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [14.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: yarn 27 | - run: yarn build 28 | - run: yarn lint 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /shared/utils/validator-decorators/isNilOrString.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationOptions, 4 | ValidatorConstraint, 5 | ValidatorConstraintInterface, 6 | ValidationArguments, 7 | isString, 8 | } from 'class-validator' 9 | import { isNil } from 'lodash' 10 | 11 | @ValidatorConstraint({ async: true }) 12 | class IsNilOrStringConstraint implements ValidatorConstraintInterface { 13 | validate(value: any, _args: ValidationArguments) { 14 | return isNil(value) || isString(value) 15 | } 16 | } 17 | 18 | export function IsNilOrString(validationOptions?: ValidationOptions) { 19 | return function (object: Object, propertyName: string) { 20 | registerDecorator({ 21 | target: object.constructor, 22 | propertyName: propertyName, 23 | options: validationOptions, 24 | constraints: [], 25 | validator: IsNilOrStringConstraint, 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql/models/category.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-01 14:46:37 4 | * @LastEditTime: 2020-10-02 13:31:29 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/graphql/models/category.model.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import Category, { CategoryType } from '@libs/db/models/category.model' 10 | import { Field, ObjectType, registerEnumType } from '@nestjs/graphql' 11 | import { BaseGLModel } from './base.model' 12 | import { PostItemModel } from './post.model' 13 | 14 | registerEnumType(CategoryType, { 15 | name: 'CategoryType_', 16 | }) 17 | 18 | @ObjectType() 19 | export class CategoryItemModel extends BaseGLModel implements Category { 20 | name: string 21 | slug: string 22 | 23 | @Field(() => CategoryType) 24 | type: CategoryType 25 | 26 | @Field(() => [PostItemModel], { nullable: true }) 27 | children?: PostItemModel[] 28 | } 29 | -------------------------------------------------------------------------------- /shared/core/middlewares/security.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common' 2 | import { IncomingMessage, ServerResponse } from 'http' 3 | import { URL } from 'url' 4 | // 用于屏蔽 PHP 的请求 5 | 6 | @Injectable() 7 | export class SecurityMiddleware implements NestMiddleware { 8 | async use(req: IncomingMessage, res: ServerResponse, next: () => void) { 9 | // @ts-ignore 10 | const url = new URL('http://a.com' + req.originalUrl).pathname 11 | 12 | if (url.match(/\.php$/g)) { 13 | res.statusMessage = 14 | 'Eh. PHP is not support on this machine. Yep, I also think PHP is bestest programming language. But for me it is beyond my reach.' 15 | return res.writeHead(666).end() 16 | } else if (url.match(/\/(adminer|admin|wp-login)$/g)) { 17 | res.statusMessage = 'Hey, What the fuck are you doing!' 18 | return res.writeHead(200).end() 19 | } else next() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /shared/utils/time.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-11 13:53:31 4 | * @LastEditTime: 2020-06-03 10:34:25 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/shared/utils/time.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import * as dayjs from 'dayjs' 11 | 12 | export const getTodayEarly = (today: Date) => 13 | dayjs(today).set('hour', 0).set('minute', 0).set('millisecond', 0).toDate() 14 | 15 | export const getWeekStart = (today: Date) => 16 | dayjs(today) 17 | .set('day', 0) 18 | .set('hour', 0) 19 | .set('millisecond', 0) 20 | .set('minute', 0) 21 | .toDate() 22 | 23 | export const getMonthStart = (today: Date) => 24 | dayjs(today) 25 | .set('date', 1) 26 | .set('hour', 0) 27 | .set('minute', 0) 28 | .set('millisecond', 0) 29 | .toDate() 30 | 31 | export function getMonthLength(month: number, year: number) { 32 | return new Date(year, month, 0).getDate() 33 | } 34 | -------------------------------------------------------------------------------- /configs/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2; 3 | listen [::]:443 ssl http2; 4 | 5 | server_name example.com; 6 | root /var/www/example.com/public; 7 | 8 | # SSL 9 | ssl_certificate ${cert}; 10 | ssl_certificate_key ${key}; 11 | 12 | # security 13 | include security.conf; 14 | 15 | # reverse proxy 16 | location / { 17 | proxy_pass http://127.0.0.1:2333; 18 | include proxy.conf; 19 | } 20 | # additional config 21 | include general.conf; 22 | } 23 | 24 | # subdomains redirect 25 | server { 26 | listen 443 ssl http2; 27 | listen [::]:443 ssl http2; 28 | 29 | server_name example.com; 30 | 31 | # SSL 32 | ssl_certificate ${cert}; 33 | ssl_certificate_key ${key}; 34 | 35 | return 301 https://example.com$request_uri; 36 | } 37 | 38 | # HTTP redirect 39 | server { 40 | listen 80; 41 | listen [::]:80; 42 | 43 | server_name .example.com; 44 | 45 | location / { 46 | return 301 https://example.com$request_uri; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/server/src/shared/page/page.service.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-05-31 18:26:40 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/shared/page/page.service.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import Page from '@libs/db/models/page.model' 11 | import { HttpService, Injectable } from '@nestjs/common' 12 | import { ReturnModelType } from '@typegoose/typegoose' 13 | import { InjectModel } from 'nestjs-typegoose' 14 | import { ConfigsService } from '../../../../../shared/global' 15 | import { WriteBaseService } from '../base/base.service' 16 | 17 | @Injectable() 18 | export class PageService extends WriteBaseService { 19 | constructor( 20 | @InjectModel(Page) private readonly pageModel: ReturnModelType, 21 | private readonly http: HttpService, 22 | private readonly configs: ConfigsService, 23 | ) { 24 | super(pageModel, http, configs) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/server/src/shared/aggregate/dtos/timeline.dto.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-05-25 14:45:59 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/shared/aggregate/dtos/timeline.dto.ts 7 | * @Copyright 8 | */ 9 | 10 | import { ApiProperty } from '@nestjs/swagger' 11 | import { Transform } from 'class-transformer' 12 | import { IsEnum, IsInt, IsOptional } from 'class-validator' 13 | 14 | export enum TimelineType { 15 | Post, 16 | Note, 17 | } 18 | 19 | export class TimelineQueryDto { 20 | @Transform(({ value: val }) => Number(val)) 21 | @IsEnum([1, -1]) 22 | @IsOptional() 23 | @ApiProperty({ enum: [-1, 1] }) 24 | sort?: -1 | 1 25 | 26 | @Transform(({ value: val }) => Number(val)) 27 | @IsInt() 28 | @IsOptional() 29 | year?: number 30 | 31 | @IsEnum(TimelineType) 32 | @IsOptional() 33 | @ApiProperty({ enum: [0, 1] }) 34 | @Transform(({ value: v }) => v | 0) 35 | type?: TimelineType 36 | } 37 | -------------------------------------------------------------------------------- /apps/server/src/shared/base/dto/search.dto.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-08-02 16:27:30 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/shared/base/dto/search.dto.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { ApiProperty } from '@nestjs/swagger' 11 | import { Transform } from 'class-transformer' 12 | import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator' 13 | import { PagerDto } from 'apps/server/src/shared/base/dto/pager.dto' 14 | 15 | export class SearchDto extends PagerDto { 16 | @IsNotEmpty() 17 | @IsString() 18 | @ApiProperty() 19 | keyword: string 20 | 21 | @IsString() 22 | @ApiProperty({ description: '根据什么排序', required: false }) 23 | @IsNotEmpty() 24 | @IsOptional() 25 | orderBy: string 26 | 27 | @Transform(({ value: val }) => parseInt(val)) 28 | @IsEnum([1, -1]) 29 | @IsOptional() 30 | @ApiProperty({ description: '倒序|正序', enum: [1, -1], required: false }) 31 | order: number 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | 6 | name: Upload Release Asset 7 | 8 | jobs: 9 | create_release: 10 | name: Upload Release Asset 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | runs-on: ${{ matrix.os }} 15 | env: 16 | KERNEL: $(uname | tr '[:upper:]' '[:lower:]') 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | - uses: actions/setup-node@v2 22 | with: 23 | node-version: 14.x 24 | - name: Build project 25 | run: | 26 | sh release.sh 27 | - name: Build 28 | run: echo ${{ github.sha }} > Release.txt 29 | - name: Test 30 | run: cat Release.txt 31 | - name: Release 32 | uses: softprops/action-gh-release@v1 33 | if: startsWith(github.ref, 'refs/tags/') 34 | with: 35 | files: | 36 | release.zip 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /shared/core/decorators/guest.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 2 | import { GqlExecutionContext } from '@nestjs/graphql' 3 | 4 | export const Guest = createParamDecorator( 5 | (data: unknown, ctx: ExecutionContext) => { 6 | const request = ctx.switchToHttp().getRequest() 7 | return request.isGuest 8 | }, 9 | ) 10 | 11 | export const Master = createParamDecorator( 12 | (data: unknown, ctx: ExecutionContext) => { 13 | const request = ctx.switchToHttp().getRequest() 14 | return request.isMaster 15 | }, 16 | ) 17 | 18 | export const GuestGQL = createParamDecorator( 19 | (data: unknown, ctx: ExecutionContext) => { 20 | const request = GqlExecutionContext.create(ctx).getContext().req 21 | return request.isGuest 22 | }, 23 | ) 24 | 25 | export const MasterGQL = createParamDecorator( 26 | (data: unknown, ctx: ExecutionContext) => { 27 | const request = GqlExecutionContext.create(ctx).getContext().req 28 | 29 | return request.isMaster 30 | }, 31 | ) 32 | -------------------------------------------------------------------------------- /patch/v1.5.1.ts: -------------------------------------------------------------------------------- 1 | import { patch } from './bootstrap' 2 | 3 | export enum MoodSet { 4 | 'happy' = '开心', 5 | 'sad' = '伤心', 6 | 'angry' = '生气', 7 | 'sorrow' = '悲哀', 8 | 'pain' = '痛苦', 9 | 'terrible' = '可怕', 10 | 'unhappy' = '不快', 11 | 'detestable' = '可恶', 12 | 'worry' = '担心', 13 | 'despair' = '绝望', 14 | 'anxiety' = '焦虑', 15 | 'excite' = '激动', 16 | } 17 | 18 | export enum WeatherSet { 19 | 'sunshine' = '晴', 20 | 'cloudy' = '多云', 21 | 'rainy' = '雨', 22 | 'overcast' = '阴', 23 | 'snow' = '雪', 24 | } 25 | 26 | patch(async ({ models }) => { 27 | const Note = models.note 28 | 29 | const docs = await Note.find() 30 | 31 | await Promise.all( 32 | docs.map(async (doc) => { 33 | const weather = doc.weather 34 | const mood = doc.mood 35 | 36 | if (weather) { 37 | doc.weather = WeatherSet[weather] ?? doc.weather 38 | } 39 | if (mood) { 40 | doc.mood = MoodSet[mood] ?? doc.mood 41 | } 42 | 43 | await doc.save() 44 | return doc 45 | }), 46 | ) 47 | }) 48 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/pages/pages.service.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-04 09:23:05 4 | * @LastEditTime: 2020-10-04 09:42:11 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/pages/pages.service.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import Page from '@libs/db/models/page.model' 10 | import { Injectable } from '@nestjs/common' 11 | import { ReturnModelType } from '@typegoose/typegoose' 12 | import { isMongoId } from 'class-validator' 13 | import { InjectModel } from 'nestjs-typegoose' 14 | import { BaseService } from '../base/base.service' 15 | 16 | @Injectable() 17 | export class PagesService extends BaseService { 18 | constructor( 19 | @InjectModel(Page) private readonly model: ReturnModelType, 20 | ) { 21 | super(model) 22 | } 23 | 24 | async getPageByIdOrSlug(unique: any) { 25 | return isMongoId(unique) 26 | ? await super.findByIdException(unique) 27 | : await super.findOneAsyncException({ 28 | slug: unique, 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/server/src/shared/comments/dto/pager.dto.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-11-24 16:20:37 4 | * @LastEditTime: 2021-01-15 13:56:29 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/shared/comments/dto/pager.dto.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { ApiProperty } from '@nestjs/swagger' 10 | import { Transform } from 'class-transformer' 11 | import { IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator' 12 | 13 | export class Pager { 14 | @Transform(({ value: val }) => parseInt(val)) 15 | @IsOptional() 16 | @IsNumber() 17 | @Min(1) 18 | @ApiProperty({ required: false, minimum: 1, example: 1 }) 19 | page?: number 20 | 21 | @Transform(({ value: val }) => parseInt(val)) 22 | @IsOptional() 23 | @IsNumber() 24 | @Max(50) 25 | @Min(1) 26 | @ApiProperty({ required: false, minimum: 1, maximum: 50, example: 1 }) 27 | size?: number 28 | 29 | @Transform(({ value: val }) => parseInt(val)) 30 | @IsEnum([0, 1, 2]) 31 | @ApiProperty({ enum: [0, 1, 2] }) 32 | state?: number 33 | } 34 | -------------------------------------------------------------------------------- /shared/core/adapt/fastify.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-06-24 20:01:32 4 | * @LastEditTime: 2020-10-21 19:16:37 5 | * @LastEditors: Innei 6 | * @FilePath: /server/src/core/adapt/fastify.ts 7 | * @Coding with Love 8 | */ 9 | import * as FastifyMultipart from 'fastify-multipart' 10 | import type _FastifyMultipart from 'fastify-multipart' 11 | import { FastifyAdapter } from '@nestjs/platform-fastify' 12 | import { isDev } from '../../utils' 13 | 14 | export const fastifyApp = new FastifyAdapter({ 15 | logger: isDev, 16 | trustProxy: true, 17 | }) 18 | fastifyApp.register(FastifyMultipart as any as typeof _FastifyMultipart, { 19 | limits: { 20 | fields: 10, // Max number of non-file fields 21 | fileSize: 1024 * 1024 * 6, // limit size 6M 22 | files: 5, // Max number of file fields 23 | }, 24 | }) 25 | 26 | fastifyApp.getInstance().addHook('onRequest', (request, reply, done) => { 27 | const origin = request.headers.origin 28 | if (!origin) { 29 | request.headers.origin = request.headers.host 30 | } 31 | 32 | done() 33 | }) 34 | -------------------------------------------------------------------------------- /shared/core/interceptors/http-cache.interceptors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | CacheInterceptor, 4 | ExecutionContext, 5 | CACHE_KEY_METADATA, 6 | } from '@nestjs/common' 7 | import { Reflector } from '@nestjs/core' 8 | import { IncomingMessage } from 'node:http' 9 | import { CACHE_KEY_PREFIX } from 'shared/constants' 10 | 11 | /* 12 | * @Author: Innei 13 | * @Date: 2021-03-21 19:29:52 14 | * @LastEditTime: 2021-03-21 20:04:08 15 | * @LastEditors: Innei 16 | * @FilePath: /server/shared/core/interceptors/http-cache.interceptors.ts 17 | * Mark: Coding with Love 18 | */ 19 | 20 | @Injectable() 21 | export class HttpCacheInterceptor extends CacheInterceptor { 22 | trackBy(context: ExecutionContext): string | undefined { 23 | const http = context.switchToHttp() 24 | const meta = (this.reflector as Reflector).get( 25 | CACHE_KEY_METADATA, 26 | context.getHandler(), 27 | ) 28 | 29 | const req = http.getRequest() as IncomingMessage 30 | const path = req.url 31 | 32 | return CACHE_KEY_PREFIX + (meta ? `name:${meta}` : path) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /shared/core/guards/spider.guard.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 19:09:37 4 | * @LastEditTime: 2020-07-08 21:35:06 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/core/guards/spider.guard.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { 11 | CanActivate, 12 | ExecutionContext, 13 | ForbiddenException, 14 | Injectable, 15 | } from '@nestjs/common' 16 | import { FastifyRequest } from 'fastify' 17 | import { Observable } from 'rxjs' 18 | 19 | @Injectable() 20 | export class SpiderGuard implements CanActivate { 21 | canActivate( 22 | context: ExecutionContext, 23 | ): boolean | Promise | Observable { 24 | const http = context.switchToHttp() 25 | const request = http.getRequest() 26 | const headers = request.headers 27 | // const { referer } = headers 28 | const ua: string = headers['user-agent'] || '' 29 | const isSpiderUA = !!ua.match(/(Scrapy|Curl|HttpClient|python|requests)/i) 30 | if (ua && !isSpiderUA) { 31 | return true 32 | } 33 | throw new ForbiddenException('爬虫, 禁止') 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 18:14:55 4 | * @LastEditTime: 2020-05-25 21:05:26 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/ecosystem.config.js 7 | * @Copyright 8 | */ 9 | 10 | const env = require('dotenv').config().parsed 11 | module.exports = { 12 | apps: [ 13 | { 14 | name: 'mx-space-server', 15 | script: 'dist/apps/server/main.js', 16 | autorestart: true, 17 | instances: 'max', 18 | exec_mode: 'cluster', 19 | watch: false, 20 | // instances: 1, 21 | // max_memory_restart: env.APP_MAX_MEMORY || '150M', 22 | env: { 23 | NODE_ENV: 'production', 24 | ...env, 25 | }, 26 | }, 27 | { 28 | name: 'mx-space-graphql', 29 | script: 'dist/apps/graphql/main.js', 30 | autorestart: true, 31 | instances: 'max', 32 | exec_mode: 'cluster', 33 | watch: false, 34 | // instances: 1, 35 | // max_memory_restart: env.APP_MAX_MEMORY || '150M', 36 | env: { 37 | NODE_ENV: 'production', 38 | ...env, 39 | }, 40 | }, 41 | ], 42 | } 43 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql/models/page.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-03 10:32:06 4 | * @LastEditTime: 2020-10-04 09:29:17 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/graphql/models/page.model.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import Page from '@libs/db/models/page.model' 10 | import { Field, Int, ObjectType } from '@nestjs/graphql' 11 | import { 12 | PagerModel, 13 | PagerModelImplements, 14 | TextModelImplementsImageRecordModel, 15 | } from './base.model' 16 | 17 | @ObjectType() 18 | export class PageItemModel 19 | extends TextModelImplementsImageRecordModel 20 | implements Page { 21 | allowComment: boolean 22 | @Field(() => Int) 23 | commentsIndex: number 24 | 25 | @Field(() => Int) 26 | order: number 27 | 28 | slug: string 29 | 30 | subtitle?: string 31 | 32 | created: Date 33 | modified: Date 34 | } 35 | 36 | @ObjectType() 37 | export class PagePagerModel implements PagerModelImplements { 38 | @Field(() => [PageItemModel], { nullable: true }) 39 | data: PageItemModel[] 40 | 41 | @Field(() => PagerModel) 42 | pager: PagerModel 43 | } 44 | -------------------------------------------------------------------------------- /apps/server/src/shared/aggregate/dtos/random.dto.ts: -------------------------------------------------------------------------------- 1 | import { FileType } from '@libs/db/models/file.model' 2 | import { ApiProperty } from '@nestjs/swagger' 3 | import { Transform } from 'class-transformer' 4 | import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator' 5 | import { range } from 'lodash' 6 | 7 | // export const RandomType = ['POST', 'NOTE', 'SAY', 'IMAGE'] 8 | export enum RandomType { 9 | POST, 10 | NOTE, 11 | SAY, 12 | IMAGE, 13 | } 14 | 15 | export class RandomTypeDto { 16 | @Transform(({ value: val }) => Number(val)) 17 | @IsEnum(RandomType) 18 | @ApiProperty({ 19 | enum: range(4), 20 | description: '0 - POST, 1 - NOTE, 2 - SAY, 3 - IMAGE', 21 | }) 22 | type: RandomType 23 | 24 | @Transform(({ value: val }) => Number(val)) 25 | @IsOptional() 26 | @IsEnum(FileType) 27 | @ApiProperty({ 28 | enum: range(3), 29 | description: '0 - IMAGE, 1 - AVATAR, 2 - BACKGROUND, 3 - PHOTO', 30 | }) 31 | imageType?: FileType 32 | 33 | @Transform(({ value: val }) => Number(val)) 34 | @Min(1) 35 | @Max(10) 36 | @IsInt() 37 | @IsOptional() 38 | size?: number 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "lib": [ 16 | "ES2019", 17 | "ES2020.String" 18 | ], 19 | "paths": { 20 | "@libs/db": [ 21 | "libs/db/src" 22 | ], 23 | "@libs/db/*": [ 24 | "libs/db/src/*" 25 | ], 26 | "@libs/common": [ 27 | "libs/common/src" 28 | ], 29 | "@libs/common/*": [ 30 | "libs/common/src/*" 31 | ], 32 | "core/*": [ 33 | "shared/core/*" 34 | ], 35 | "utils/*": [ 36 | "shared/utils/*" 37 | ], 38 | "core": [ 39 | "shared/core" 40 | ], 41 | "utils": [ 42 | "shared/utils" 43 | ] 44 | } 45 | }, 46 | "exclude": [ 47 | "node_modules", 48 | "dist" 49 | ] 50 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mix Space 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/server/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-07-31 19:52:35 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/auth/jwt.strategy.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { Injectable, UnauthorizedException } from '@nestjs/common' 11 | import { PassportStrategy } from '@nestjs/passport' 12 | import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt' 13 | import { AuthService } from './auth.service' 14 | import { JwtPayload } from './interfaces/jwt-payload.interface' 15 | 16 | @Injectable() 17 | export class JwtStrategy extends PassportStrategy(Strategy) { 18 | constructor(private readonly authService: AuthService) { 19 | super({ 20 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 21 | secretOrKey: process.env.SECRET || 'asdhaisouxcjzuoiqdnasjduw', 22 | ignoreExpiration: false, 23 | } as StrategyOptions) 24 | } 25 | 26 | async validate(payload: JwtPayload) { 27 | const user = await this.authService.verifyPayload(payload) 28 | if (user) { 29 | return user 30 | } 31 | throw new UnauthorizedException('身份已过期') 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/server/src/master/master.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-10-21 18:55:04 5 | * @LastEditors: Innei 6 | * @FilePath: /server/src/master/master.module.ts 7 | * @Copyright 8 | */ 9 | 10 | import { Global, Module } from '@nestjs/common' 11 | import { AuthModule } from 'apps/server/src/auth/auth.module' 12 | import { MasterController } from './master.controller' 13 | import MasterService from './master.service' 14 | import { RedisModule } from 'nestjs-redis' 15 | 16 | @Global() 17 | @Module({ 18 | imports: [AuthModule, RedisModule], 19 | providers: [MasterService], 20 | controllers: [MasterController], 21 | exports: [MasterService], 22 | }) 23 | export class MasterModule { 24 | constructor(private service: MasterService) { 25 | service.hasMaster().then((res) => { 26 | if (!res) { 27 | service 28 | .createMaster({ 29 | name: 'master', 30 | username: 'master', 31 | password: 'master', 32 | }) 33 | // eslint-disable-next-line @typescript-eslint/no-empty-function 34 | .catch((e) => {}) 35 | } 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /shared/utils/validator-decorators/simpleValidatorFactory.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-08-02 13:00:15 4 | * @LastEditTime: 2020-08-02 13:13:01 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/common/decorators/simpleValidatorFactory.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { 11 | registerDecorator, 12 | ValidationArguments, 13 | ValidationOptions, 14 | ValidatorConstraint, 15 | ValidatorConstraintInterface, 16 | } from 'class-validator' 17 | 18 | export function validatorFactory(validator: (value: any) => boolean) { 19 | @ValidatorConstraint({ async: true }) 20 | class IsBooleanOrStringConstraint implements ValidatorConstraintInterface { 21 | validate(value: any, _args: ValidationArguments) { 22 | return validator.call(this, value) 23 | } 24 | } 25 | 26 | return function (validationOptions?: ValidationOptions) { 27 | return function (object: Object, propertyName: string) { 28 | registerDecorator({ 29 | target: object.constructor, 30 | propertyName: propertyName, 31 | options: validationOptions, 32 | constraints: [], 33 | validator: IsBooleanOrStringConstraint, 34 | }) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/server/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-05-30 14:13:25 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/auth/auth.module.ts 7 | * @Copyright 8 | */ 9 | 10 | import { HttpModule, Module } from '@nestjs/common' 11 | import { JwtModule } from '@nestjs/jwt' 12 | import { PassportModule } from '@nestjs/passport' 13 | import { AuthService } from './auth.service' 14 | import { JwtStrategy } from './jwt.strategy' 15 | import { LocalStrategy } from './local.strategy' 16 | import { AuthController } from './auth.controller' 17 | import { AdminEventsGateway } from '../gateway/admin/events.gateway' 18 | 19 | const jwtModule = JwtModule.registerAsync({ 20 | useFactory() { 21 | return { 22 | secret: process.env.SECRET || 'asdhaisouxcjzuoiqdnasjduw', 23 | signOptions: { 24 | expiresIn: '7d', 25 | }, 26 | } 27 | }, 28 | }) 29 | @Module({ 30 | imports: [PassportModule, jwtModule, HttpModule], 31 | providers: [AuthService, JwtStrategy, LocalStrategy, AdminEventsGateway], 32 | controllers: [AuthController], 33 | exports: [JwtStrategy, LocalStrategy, AuthService, jwtModule], 34 | }) 35 | export class AuthModule {} 36 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-11-24 16:20:37 4 | * @LastEditTime: 2021-01-15 13:48:39 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/graphql/src/graphql.module.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { DbModule } from '@libs/db' 10 | import { Module } from '@nestjs/common' 11 | import { GraphQLModule } from '@nestjs/graphql' 12 | import { ConfigsModule } from 'shared/global/configs/configs.module' 13 | import { GlobalModule } from 'shared/global/global.module' 14 | import { isDev } from 'shared/utils' 15 | import { RootController } from './root.controller' 16 | 17 | import { SharedModule } from './shared/shared.module' 18 | 19 | @Module({ 20 | imports: [ 21 | ConfigsModule, 22 | DbModule, 23 | GlobalModule, 24 | GraphQLModule.forRoot({ 25 | debug: isDev, 26 | playground: isDev, 27 | autoSchemaFile: 'schema.gql', 28 | // installSubscriptionHandlers: true, 29 | context: ({ req }) => ({ req }), 30 | // typePaths: ['./**/*.gql'], 31 | // autoSchemaFile: true, 32 | }), 33 | SharedModule, 34 | ], 35 | 36 | controllers: [RootController], 37 | providers: [], 38 | }) 39 | export class GraphqlModule {} 40 | -------------------------------------------------------------------------------- /tools/represh-md-image-links.ts: -------------------------------------------------------------------------------- 1 | import Note from '../libs/db/src/models/note.model' 2 | import Post from '../libs/db/src/models/post.model' 3 | import { getModelForClass } from '@typegoose/typegoose' 4 | import { config } from 'dotenv' 5 | import { connect, disconnect } from 'mongoose' 6 | const env = config().parsed 7 | 8 | async function main() { 9 | connect((env.DB_URL || 'mongodb://localhost') + '/mx-space', { 10 | useCreateIndex: true, 11 | useFindAndModify: false, 12 | useNewUrlParser: true, 13 | useUnifiedTopology: true, 14 | autoIndex: true, 15 | }) 16 | 17 | { 18 | const post = getModelForClass(Post) 19 | const note = getModelForClass(Note) 20 | const allPosts = await post.find() 21 | const allNotes = await note.find() 22 | 23 | for await (const post of [...allPosts, ...allNotes]) { 24 | const text = post.text 25 | 26 | post.text = text 27 | .replace( 28 | 'https://raw.githubusercontent.com/Innei/img-bed/master', 29 | 'https://cdn.jsdelivr.net/gh/innei/img-bed@master', 30 | ) 31 | .replace(/.*?.sinaimg.cn/, 'tva2.sinaimg.cn') 32 | //@ts-ignore 33 | await post.save() 34 | } 35 | } 36 | 37 | disconnect() 38 | } 39 | 40 | main() 41 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-01 13:48:50 4 | * @LastEditTime: 2020-10-01 15:23:19 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/shared.module.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { Module } from '@nestjs/common' 10 | import { CategoriesService } from './categories/categories.service' 11 | import { PostsResolver } from './posts/posts.resolver' 12 | import { PostsService } from './posts/posts.service' 13 | import { CategoriesResolver } from './categories/categories.resolver' 14 | import { NotesResolver } from './notes/notes.resolver' 15 | import { NotesService } from './notes/notes.service' 16 | import { AggregateResolver } from './aggregate/aggregate.resolver' 17 | import { AggregateService } from './aggregate/aggregate.service' 18 | import { PagesResolver } from './pages/pages.resolver' 19 | import { PagesService } from './pages/pages.service' 20 | 21 | @Module({ 22 | providers: [ 23 | PostsService, 24 | CategoriesService, 25 | PostsResolver, 26 | CategoriesResolver, 27 | NotesResolver, 28 | NotesService, 29 | AggregateResolver, 30 | AggregateService, 31 | PagesResolver, 32 | PagesService, 33 | ], 34 | }) 35 | export class SharedModule {} 36 | -------------------------------------------------------------------------------- /shared/utils/pic.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-31 16:10:03 4 | * @LastEditTime: 2021-03-21 19:01:30 5 | * @LastEditors: Innei 6 | * @FilePath: /server/shared/utils/pic.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import Vibrant = require('node-vibrant') 11 | import { ISizeCalculationResult } from 'image-size/dist/types/interface' 12 | 13 | export const pickImagesFromMarkdown = (text: string) => { 14 | const reg = /(?<=\!\[.*\]\()(.+)(?=\))/g 15 | const images = [] as string[] 16 | for (const r of text.matchAll(reg)) { 17 | images.push(r[0]) 18 | } 19 | return images 20 | } 21 | 22 | export async function getAverageRGB( 23 | buffer: Buffer, 24 | size: ISizeCalculationResult, 25 | ): Promise { 26 | if (!buffer) { 27 | return undefined 28 | } 29 | try { 30 | const res = await Vibrant.from(buffer).getPalette() 31 | 32 | return res.Muted.hex 33 | } catch { 34 | return undefined 35 | } 36 | } 37 | 38 | function componentToHex(c: number) { 39 | const hex = c.toString(16) 40 | return hex.length == 1 ? '0' + hex : hex 41 | } 42 | 43 | export function rgbToHex({ r, g, b }: { r: number; g: number; b: number }) { 44 | return '#' + componentToHex(r) + componentToHex(g) + componentToHex(b) 45 | } 46 | -------------------------------------------------------------------------------- /apps/server/src/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@libs/db/models/user.model' 2 | import { ForbiddenException } from '@nestjs/common' 3 | import { PassportStrategy } from '@nestjs/passport' 4 | import { ReturnModelType } from '@typegoose/typegoose' 5 | import { compareSync } from 'bcrypt' 6 | import { InjectModel } from 'nestjs-typegoose' 7 | import { IStrategyOptions, Strategy } from 'passport-local' 8 | 9 | function sleep(ms: number) { 10 | return new Promise((resolve) => setTimeout(resolve, ms)) 11 | } 12 | 13 | export class LocalStrategy extends PassportStrategy(Strategy, 'local') { 14 | constructor( 15 | @InjectModel(User) private userModel: ReturnModelType, 16 | ) { 17 | super({ 18 | usernameField: 'username', 19 | passwordField: 'password', 20 | } as IStrategyOptions) 21 | } 22 | 23 | async validate(username: string, password: string) { 24 | const user = await this.userModel.findOne({ username }).select('+password') 25 | if (!user) { 26 | await sleep(3000) 27 | throw new ForbiddenException('用户名不正确') 28 | } 29 | if (!compareSync(password, user.password)) { 30 | await sleep(3000) 31 | throw new ForbiddenException('密码不正确') 32 | } 33 | // console.log(user) 34 | return user 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /patch/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import Category from '../libs/db/src/models/category.model' 2 | import Note from '../libs/db/src/models/note.model' 3 | import Post from '../libs/db/src/models/post.model' 4 | import { getModelForClass, mongoose } from '@typegoose/typegoose' 5 | import { config } from 'dotenv' 6 | import { Analyze } from '../libs/db/src/models/analyze.model' 7 | import { ConnectionBase } from 'mongoose' 8 | 9 | const env = config().parsed || {} 10 | const url = 11 | (process.env.DB_URL || env.DB_URL || 'mongodb://localhost') + '/mx-space' 12 | const opt = { 13 | useCreateIndex: true, 14 | useFindAndModify: false, 15 | useNewUrlParser: true, 16 | useUnifiedTopology: true, 17 | autoIndex: true, 18 | } 19 | mongoose.connect(url, opt) 20 | const post = getModelForClass(Post) 21 | const note = getModelForClass(Note) 22 | const category = getModelForClass(Category) 23 | const analyze = getModelForClass(Analyze) 24 | const Config = { 25 | env, 26 | db: (mongoose.connection as any).client.db('mx-space') as ConnectionBase, 27 | models: { 28 | post, 29 | note, 30 | category, 31 | analyze, 32 | }, 33 | } 34 | async function bootstrap(cb: (config: typeof Config) => any) { 35 | await cb.call(this, Config) 36 | 37 | mongoose.disconnect() 38 | process.exit() 39 | } 40 | 41 | export { bootstrap as patch } 42 | -------------------------------------------------------------------------------- /apps/server/src/gateway/base.gateway.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketServer } from '@nestjs/websockets' 2 | import { EventTypes } from './events.types' 3 | 4 | export function gatewayMessageFormat(type: EventTypes, message: any) { 5 | return { 6 | type, 7 | data: message, 8 | } 9 | } 10 | export class BaseGateway { 11 | @WebSocketServer() 12 | server: SocketIO.Server 13 | wsClients: SocketIO.Socket[] = [] 14 | 15 | async broadcast(event: EventTypes, message: any) { 16 | // this.server.clients().send() 17 | for (const c of this.wsClients) { 18 | c.send(gatewayMessageFormat(event, message)) 19 | } 20 | } 21 | get messageFormat() { 22 | return gatewayMessageFormat 23 | } 24 | handleDisconnect(client: SocketIO.Socket) { 25 | for (let i = 0; i < this.wsClients.length; i++) { 26 | if (this.wsClients[i] === client) { 27 | this.wsClients.splice(i, 1) 28 | break 29 | } 30 | } 31 | client.send( 32 | gatewayMessageFormat(EventTypes.GATEWAY_CONNECT, 'WebSocket 断开'), 33 | ) 34 | } 35 | handleConnect(client: SocketIO.Socket) { 36 | client.send( 37 | gatewayMessageFormat(EventTypes.GATEWAY_CONNECT, 'WebSocket 已连接'), 38 | ) 39 | } 40 | findClientById(id: string) { 41 | return this.wsClients.find((socket) => socket.id === id) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /bin/patch.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const inquirer = require('inquirer') 3 | const chalk = require('chalk') 4 | const prompt = inquirer.createPromptModule() 5 | const package = require('../package.json') 6 | const { execSync } = require('child_process') 7 | const { resolve } = require('path') 8 | const { readdirSync } = require('fs') 9 | const PATCH_DIR = resolve(process.cwd(), './patch') 10 | 11 | async function bootstarp() { 12 | console.log(chalk.yellowBright('mx-space server patch center')) 13 | 14 | console.log(chalk.yellow(`current version: ${package.version}`)) 15 | 16 | const patchFiles = readdirSync(PATCH_DIR).filter( 17 | (file) => file.startsWith('v') && file.endsWith('.ts'), 18 | ) 19 | 20 | prompt({ 21 | type: 'list', 22 | name: 'version', 23 | message: 'Select version you want to patch.', 24 | choices: patchFiles.map((f) => f.replace(/\.ts$/, '')), 25 | }).then(({ version }) => { 26 | try { 27 | execSync('ts-node -v') 28 | console.log(chalk.green('ts-node is ready.')) 29 | } catch { 30 | console.log(chalk.red('ts-node is not installed.')) 31 | process.exit(-1) 32 | } 33 | const patchPath = resolve(PATCH_DIR, version + '.ts') 34 | console.log(chalk.green('starting patch... ' + patchPath)) 35 | execSync(`ts-node ${patchPath}`) 36 | }) 37 | } 38 | 39 | bootstarp() 40 | -------------------------------------------------------------------------------- /apps/server/src/shared/comments/dto/comment.dto.ts: -------------------------------------------------------------------------------- 1 | import { CommentRefTypes } from '@libs/db/models/comment.model' 2 | import { ApiProperty } from '@nestjs/swagger' 3 | import { 4 | IsEmail, 5 | IsEnum, 6 | IsNotEmpty, 7 | IsOptional, 8 | IsString, 9 | IsUrl, 10 | MaxLength, 11 | } from 'class-validator' 12 | 13 | export class CommentDto { 14 | @IsString() 15 | @IsNotEmpty() 16 | @ApiProperty() 17 | @MaxLength(20, { message: '昵称不得大于 20 个字符' }) 18 | author: string 19 | 20 | @IsString() 21 | @IsNotEmpty() 22 | @ApiProperty() 23 | @MaxLength(500, { message: '评论内容不得大于 500 个字符' }) 24 | text: string 25 | 26 | @IsString() 27 | @IsEmail(undefined, { message: '请更正为正确的邮箱' }) 28 | @ApiProperty({ example: 'test@mail.com' }) 29 | @MaxLength(50, { message: '邮箱地址不得大于 50 个字符' }) 30 | mail: string 31 | 32 | @IsString() 33 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 34 | @IsOptional() 35 | @ApiProperty({ example: 'http://example.com' }) 36 | @MaxLength(50, { message: '地址不得大于 50 个字符' }) 37 | url?: string 38 | } 39 | 40 | export class TextOnlyDto { 41 | @IsString() 42 | @IsNotEmpty() 43 | @ApiProperty() 44 | text: string 45 | } 46 | 47 | export class CommentRefTypesDto { 48 | @IsOptional() 49 | @IsEnum(CommentRefTypes) 50 | @ApiProperty({ enum: CommentRefTypes, required: false }) 51 | ref: CommentRefTypes 52 | } 53 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql/models/aggregate.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-03 10:21:19 4 | * @LastEditTime: 2020-10-03 10:45:04 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/graphql/models/aggregate.model.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { Field, ObjectType, PartialType } from '@nestjs/graphql' 10 | import { MasterModel } from './master.model' 11 | import { NoteItemModel } from './note.model' 12 | import { PageItemModel } from './page.model' 13 | import { PostItemModel } from './post.model' 14 | 15 | @ObjectType() 16 | class User extends PartialType(MasterModel) {} 17 | 18 | @ObjectType() 19 | class TopModel { 20 | @Field(() => [PostItemModel], { nullable: true }) 21 | notes: NoteItemModel[] 22 | 23 | @Field(() => [PostItemModel], { nullable: true }) 24 | posts: PostItemModel[] 25 | } 26 | 27 | @ObjectType() 28 | export class AggregateQueryModel { 29 | @Field(() => User) 30 | user: User 31 | 32 | @Field(() => NoteItemModel) 33 | lastestNote: NoteItemModel 34 | 35 | @Field(() => [PageItemModel], { nullable: true }) 36 | pages: PageItemModel[] 37 | 38 | top: TopModel 39 | } 40 | 41 | @ObjectType() 42 | export class TimelineModel { 43 | @Field(() => [NoteItemModel], { nullable: true }) 44 | notes: NoteItemModel[] 45 | 46 | @Field(() => [PostItemModel], { nullable: true }) 47 | posts: PostItemModel[] 48 | } 49 | -------------------------------------------------------------------------------- /apps/server/src/shared/helper/dto/datatype.dto.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-11-24 16:20:37 4 | * @LastEditTime: 2021-01-15 13:58:30 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/shared/helper/dto/datatype.dto.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { Transform, Type } from 'class-transformer' 10 | import { 11 | IsDate, 12 | IsEnum, 13 | IsOptional, 14 | IsString, 15 | ValidateNested, 16 | } from 'class-validator' 17 | 18 | export class MetaDto { 19 | @IsString() 20 | title: string 21 | 22 | @Transform(({ value: v }) => new Date(v)) 23 | @IsDate() 24 | date: Date 25 | 26 | @Transform(({ value: v }) => new Date(v)) 27 | @IsDate() 28 | @IsOptional() 29 | updated?: Date 30 | 31 | @IsString({ each: true }) 32 | @IsOptional() 33 | categories?: Array 34 | 35 | @IsString({ each: true }) 36 | @IsOptional() 37 | tags?: string[] 38 | 39 | @IsString() 40 | slug: string 41 | } 42 | 43 | export class DatatypeDto { 44 | @ValidateNested() 45 | @IsOptional() 46 | @Type(() => MetaDto) 47 | meta: MetaDto 48 | 49 | @IsString() 50 | text: string 51 | } 52 | 53 | export enum ArticleType { 54 | Post, 55 | Note, 56 | } 57 | 58 | export class DataListDto { 59 | @IsEnum(ArticleType) 60 | type: ArticleType 61 | @ValidateNested({ each: true }) 62 | @Type(() => DatatypeDto) 63 | data: DatatypeDto[] 64 | } 65 | -------------------------------------------------------------------------------- /apps/server/src/gateway/events.types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-21 11:05:42 4 | * @LastEditTime: 2020-06-07 15:15:08 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/gateway/events.types.ts 7 | * @MIT 8 | */ 9 | 10 | export enum EventTypes { 11 | GATEWAY_CONNECT = 'GATEWAY_CONNECT', 12 | GATEWAY_DISCONNECT = 'GATEWAY_DISCONNECT', 13 | 14 | VISITOR_ONLINE = 'VISITOR_ONLINE', 15 | VISITOR_OFFLINE = 'VISITOR_OFFLINE', 16 | 17 | AUTH_FAILED = 'AUTH_FAILED', 18 | 19 | COMMENT_CREATE = 'COMMENT_CREATE', 20 | 21 | POST_CREATE = 'POST_CREATE', 22 | POST_UPDATE = 'POST_UPDATE', 23 | POST_DELETE = 'POST_DELETE', 24 | 25 | NOTE_CREATE = 'NOTE_CREATE', 26 | NOTE_UPDATE = 'NOTE_UPDATE', 27 | NOTE_DELETE = 'NOTE_DELETE', 28 | 29 | PAGE_UPDATED = 'PAGE_UPDATED', 30 | 31 | SAY_CREATE = 'SAY_CREATE', 32 | SAY_DELETE = 'SAY_DELETE', 33 | SAY_UPDATE = 'SAY_UPDATE', 34 | 35 | LINK_APPLY = 'LINK_APPLY', 36 | 37 | DANMAKU_CREATE = 'DANMAKU_CREATE', 38 | 39 | RECENTLY_CREATE = 'RECENTLY_CREATE', 40 | RECENTLY_DElETE = 'RECENTLY_DElETE', 41 | 42 | // util 43 | CONTENT_REFRESH = 'CONTENT_REFRESH', // 内容更新或重置 页面需要重载 44 | 45 | // for admin 46 | IMAGE_REFRESH = 'IMAGE_REFRESH', 47 | IMAGE_FETCH = 'IMAGE_FETCH', 48 | 49 | ADMIN_NOTIFICATION = 'ADMIN_NOTIFICATION', 50 | } 51 | 52 | export type NotificationTypes = 'error' | 'warn' | 'success' | 'info' 53 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-02 21:01:18 4 | * @LastEditTime: 2020-08-02 12:55:14 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/.eslintrc.js 7 | * @Copyright 8 | */ 9 | 10 | module.exports = { 11 | parser: '@typescript-eslint/parser', 12 | parserOptions: { 13 | project: 'tsconfig.json', 14 | sourceType: 'module', 15 | }, 16 | plugins: ['@typescript-eslint/eslint-plugin'], 17 | extends: [ 18 | 'plugin:@typescript-eslint/eslint-recommended', 19 | 'plugin:@typescript-eslint/recommended', 20 | 'prettier', 21 | ], 22 | root: true, 23 | env: { 24 | node: true, 25 | jest: true, 26 | }, 27 | rules: { 28 | '@typescript-eslint/interface-name-prefix': 'off', 29 | '@typescript-eslint/explicit-function-return-type': 'off', 30 | '@typescript-eslint/no-explicit-any': 'off', 31 | '@typescript-eslint/camelcase': 'off', 32 | '@typescript-eslint/no-var-requires': 'off', 33 | '@typescript-eslint/ban-ts-ignore': 'off', 34 | '@typescript-eslint/ban-ts-comment': 'off', 35 | '@typescript-eslint/explicit-module-boundary-types': 'off', 36 | '@typescript-eslint/ban-types': 'off', 37 | '@typescript-eslint/no-unused-vars': [ 38 | 'warn', 39 | { 40 | vars: 'all', 41 | args: 'none', 42 | ignoreRestSiblings: true, 43 | argsIgnorePattern: '^_', 44 | }, 45 | ], 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /libs/db/src/models/post.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-06 22:14:51 4 | * @LastEditTime: 2020-10-21 19:54:05 5 | * @LastEditors: Innei 6 | * @FilePath: /server/libs/db/src/models/post.model.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import Category from './category.model' 11 | import { index, prop, Ref } from '@typegoose/typegoose' 12 | import { Schema } from 'mongoose' 13 | import { WriteBaseModel } from './base.model' 14 | import { Count } from './note.model' 15 | 16 | @index({ slug: 1 }) 17 | @index({ modified: -1 }) 18 | @index({ text: 'text' }) 19 | export class Post extends WriteBaseModel { 20 | @prop({ trim: true, unique: true, required: true }) 21 | slug!: string 22 | 23 | @prop() 24 | summary?: string 25 | 26 | @prop({ ref: () => Category, required: true }) 27 | categoryId: Ref 28 | 29 | @prop({ 30 | ref: () => Category, 31 | foreignField: '_id', 32 | localField: 'categoryId', 33 | justOne: true, 34 | }) 35 | public category: Ref 36 | 37 | @prop({ default: false }) 38 | hide?: boolean 39 | 40 | @prop({ default: true }) 41 | copyright?: boolean 42 | 43 | @prop({ 44 | type: String, 45 | }) 46 | tags?: string[] 47 | 48 | @prop({ type: Count, default: { read: 0, like: 0 }, _id: false }) 49 | count?: Count 50 | 51 | @prop({ type: Schema.Types.Mixed }) 52 | options?: Record 53 | } 54 | export default Post 55 | -------------------------------------------------------------------------------- /tools/replace-cos-domain.ts: -------------------------------------------------------------------------------- 1 | import Note from '../libs/db/src/models/note.model' 2 | import Post from '../libs/db/src/models/post.model' 3 | import { getModelForClass } from '@typegoose/typegoose' 4 | import { config } from 'dotenv' 5 | import { connect, disconnect } from 'mongoose' 6 | const env = config().parsed 7 | 8 | async function main() { 9 | connect((env.DB_URL || 'mongodb://localhost') + '/mx-space', { 10 | useCreateIndex: true, 11 | useFindAndModify: false, 12 | useNewUrlParser: true, 13 | useUnifiedTopology: true, 14 | autoIndex: true, 15 | }) 16 | 17 | { 18 | const post = getModelForClass(Post) 19 | const note = getModelForClass(Note) 20 | const allPosts = await post.find() 21 | const allNotes = await note.find() 22 | 23 | for await (const post of [...allPosts, ...allNotes]) { 24 | const text = post.text 25 | 26 | post.text = text.replace( 27 | /\(https:\/\/tu-1252943311\.cos\.accelerate\.myqcloud\.com\//g, 28 | '(https://cdn.innei.ren/', 29 | ) 30 | 31 | post.images = post.images.map((image) => { 32 | image.src = image.src.replace( 33 | /\https:\/\/tu-1252943311\.cos\.accelerate\.myqcloud\.com\//g, 34 | 'https://cdn.innei.ren/', 35 | ) 36 | return image 37 | }) 38 | 39 | //@ts-ignore 40 | await post.save() 41 | } 42 | } 43 | 44 | disconnect() 45 | } 46 | 47 | main() 48 | -------------------------------------------------------------------------------- /libs/db/src/models/file.model.ts: -------------------------------------------------------------------------------- 1 | import { prop, index } from '@typegoose/typegoose' 2 | import { Schema } from 'mongoose' 3 | import { BaseModel } from './base.model' 4 | 5 | export interface Dimensions { 6 | height: number 7 | width: number 8 | type: string 9 | } 10 | 11 | export enum FileType { 12 | // image 13 | IMAGE, 14 | AVATAR, 15 | BACKGROUND, 16 | PHOTO, 17 | 18 | // not image 19 | // MUSIC, 20 | // VIDEO, 21 | // FILE, 22 | } 23 | export enum FileLocate { 24 | Local, 25 | Online, 26 | } 27 | 28 | export const getFileType = (type: FileType) => { 29 | const ft = Object.keys(FileType) 30 | return ft.splice(ft.length / 2)[type].toLowerCase() 31 | } 32 | export const getEnumFromType = (type: keyof typeof FileType) => { 33 | return { 34 | IMAGE: 0, 35 | AVATAR: 1, 36 | BACKGROUND: 2, 37 | PHOTO: 3, 38 | }[type] 39 | } 40 | 41 | @index({ filename: 1 }) 42 | @index({ name: 1 }) 43 | export class File extends BaseModel { 44 | @prop({ required: true }) 45 | filename: string 46 | 47 | @prop({ required: true }) 48 | name: string 49 | 50 | @prop() 51 | mime: string 52 | 53 | // TODO 除图片之外的其他信息 54 | @prop({ type: Schema.Types.Mixed }) 55 | info?: Record 56 | 57 | @prop() 58 | dimensions?: Dimensions 59 | 60 | @prop({ default: FileType.IMAGE }) 61 | type: number 62 | 63 | @prop({ default: FileLocate.Local }) 64 | locate: FileLocate 65 | 66 | @prop() 67 | url?: string 68 | } 69 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "apps/server/src", 4 | "projects": { 5 | "db": { 6 | "type": "library", 7 | "root": "libs/db", 8 | "entryFile": "index", 9 | "sourceRoot": "libs/db/src", 10 | "compilerOptions": { 11 | "tsConfigPath": "libs/db/tsconfig.lib.json" 12 | } 13 | }, 14 | "common": { 15 | "type": "library", 16 | "root": "libs/common", 17 | "entryFile": "index", 18 | "sourceRoot": "libs/common/src", 19 | "compilerOptions": { 20 | "tsConfigPath": "libs/common/tsconfig.lib.json" 21 | } 22 | }, 23 | "server": { 24 | "type": "application", 25 | "root": "apps/server", 26 | "entryFile": "main", 27 | "sourceRoot": "apps/server/src", 28 | "compilerOptions": { 29 | "tsConfigPath": "apps/server/tsconfig.app.json" 30 | } 31 | }, 32 | "graphql": { 33 | "type": "application", 34 | "root": "apps/graphql", 35 | "entryFile": "main", 36 | "sourceRoot": "apps/graphql/src", 37 | "compilerOptions": { 38 | "tsConfigPath": "apps/graphql/tsconfig.app.json", 39 | "plugins": [ 40 | "@nestjs/graphql/plugin" 41 | ] 42 | } 43 | } 44 | }, 45 | "compilerOptions": { 46 | "plugins": [ 47 | "@nestjs/swagger/plugin" 48 | ], 49 | "tsConfigPath": "apps/server/tsconfig.app.json" 50 | }, 51 | "monorepo": true, 52 | "root": "apps/server" 53 | } -------------------------------------------------------------------------------- /apps/graphql/src/shared/notes/notes.service.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-02 14:44:46 4 | * @LastEditTime: 2020-10-02 15:42:18 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/notes/notes.service.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import Note from '@libs/db/models/note.model' 10 | import { Injectable } from '@nestjs/common' 11 | import { ReturnModelType } from '@typegoose/typegoose' 12 | import { compareSync } from 'bcrypt' 13 | import { isMongoId } from 'class-validator' 14 | import { InjectModel } from 'nestjs-typegoose' 15 | import { BaseService } from '../base/base.service' 16 | import { DocumentType } from '@typegoose/typegoose' 17 | @Injectable() 18 | export class NotesService extends BaseService { 19 | constructor( 20 | @InjectModel(Note) private readonly model: ReturnModelType, 21 | ) { 22 | super(model) 23 | } 24 | 25 | async findOneByIdOrNid(unique: any) { 26 | const res = isMongoId(unique) 27 | ? await super.findByIdException(unique) 28 | : await super.findOneAsyncException({ 29 | nid: unique, 30 | }) 31 | 32 | return res 33 | } 34 | 35 | checkPasswordToAccess(doc: DocumentType, password: string): boolean { 36 | const hasPassword = doc.password 37 | if (!hasPassword) { 38 | return true 39 | } 40 | if (!password) { 41 | return false 42 | } 43 | const isValid = compareSync(password, doc.password) 44 | return isValid 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/posts/posts.service.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-01 15:10:04 4 | * @LastEditTime: 2020-10-01 15:17:02 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/posts/posts.service.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import Category from '@libs/db/models/category.model' 10 | import { Injectable } from '@nestjs/common' 11 | import { Ref, ReturnModelType } from '@typegoose/typegoose' 12 | import { CategoriesService } from '../categories/categories.service' 13 | import { DocumentType } from '@typegoose/typegoose' 14 | import { BaseService } from '../base/base.service' 15 | import Post from '@libs/db/models/post.model' 16 | import { InjectModel } from 'nestjs-typegoose' 17 | import { CannotFindException } from 'shared/core/exceptions/cant-find.exception' 18 | 19 | @Injectable() 20 | export class PostsService extends BaseService { 21 | constructor( 22 | private readonly categoryService: CategoriesService, 23 | @InjectModel(Post) postModel: ReturnModelType, 24 | ) { 25 | super(postModel) 26 | } 27 | async getCategoryBySlug(slug: string): Promise> { 28 | return await this.categoryService.findOne({ slug }) 29 | } 30 | 31 | async getCategoryById(id: string | Ref) { 32 | return await this.categoryService.findById(id) 33 | } 34 | 35 | async findPostById(id: string) { 36 | const doc = await super.findById(id).populate('category') 37 | if (!doc) { 38 | throw new CannotFindException() 39 | } 40 | return doc 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/server/src/shared/projects/dto/project.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { IsOptional, IsString, IsUrl } from 'class-validator' 3 | 4 | export class ProjectDto { 5 | @ApiProperty() 6 | @IsString() 7 | name: string 8 | 9 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 10 | @IsOptional() 11 | @ApiProperty({ 12 | description: '预览地址', 13 | required: false, 14 | example: 'http://example.com/image', 15 | }) 16 | previewUrl?: string 17 | 18 | @IsOptional() 19 | @ApiProperty({ 20 | description: '文档地址', 21 | required: false, 22 | example: 'http://example.com/image', 23 | }) 24 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 25 | docUrl?: string 26 | 27 | @IsOptional() 28 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 29 | @ApiProperty({ 30 | description: '项目地址', 31 | required: false, 32 | example: 'http://example.com/image', 33 | }) 34 | projectUrl?: string 35 | 36 | @ApiProperty({ 37 | description: '预览图片地址', 38 | required: true, 39 | example: ['http://example.com/image'], 40 | }) 41 | @IsUrl({ require_protocol: true }, { each: true }) 42 | images?: string[] 43 | 44 | @ApiProperty({ description: '描述', example: '这是一段描述' }) 45 | @IsString() 46 | description: string 47 | 48 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 49 | @IsOptional() 50 | @ApiProperty({ 51 | description: '头像地址', 52 | example: 'http://example.com/image', 53 | required: false, 54 | }) 55 | avatar?: string 56 | 57 | @IsString() 58 | @ApiProperty() 59 | text: string 60 | } 61 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/pages/pages.resolver.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-04 09:22:52 4 | * @LastEditTime: 2020-10-04 09:55:35 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/pages/pages.resolver.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { UnprocessableEntityException } from '@nestjs/common' 10 | import { Args, Query, Resolver } from '@nestjs/graphql' 11 | import { 12 | PagerArgsDto, 13 | IdInputArgsDtoOptional, 14 | } from '../../graphql/args/id.input' 15 | import { PageItemModel, PagePagerModel } from '../../graphql/models/page.model' 16 | 17 | import { SlugArgsDto } from '../categories/category.input' 18 | import { PagesService } from './pages.service' 19 | 20 | @Resolver((of) => PageItemModel) 21 | export class PagesResolver { 22 | constructor(private readonly service: PagesService) {} 23 | 24 | @Query(() => PagePagerModel) 25 | async getPages(@Args() args: PagerArgsDto) { 26 | const { page, size, sortBy, sortOrder } = args 27 | const res = await this.service.findWithPaginator( 28 | {}, 29 | { 30 | limit: size, 31 | skip: (page - 1) * size, 32 | sort: sortBy ? { [sortBy]: sortOrder || -1 } : { created: -1 }, 33 | }, 34 | ) 35 | 36 | return res 37 | } 38 | 39 | @Query(() => PageItemModel) 40 | async getPage( 41 | @Args() { slug }: SlugArgsDto, 42 | @Args() { id }: IdInputArgsDtoOptional, 43 | ) { 44 | if (!slug && !id) { 45 | throw new UnprocessableEntityException('id or slug must choice one') 46 | } 47 | 48 | const res = await this.service.getPageByIdOrSlug(id ?? slug) 49 | // console.log(res) 50 | 51 | return res 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /libs/db/src/models/link.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-29 12:36:28 4 | * @LastEditTime: 2021-01-17 21:11:09 5 | * @LastEditors: Innei 6 | * @FilePath: /server/libs/db/src/models/link.model.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { BaseModel } from './base.model' 11 | import { prop } from '@typegoose/typegoose' 12 | import { 13 | IsUrl, 14 | IsString, 15 | IsOptional, 16 | IsEnum, 17 | IsBoolean, 18 | IsEmail, 19 | } from 'class-validator' 20 | import { ApiProperty } from '@nestjs/swagger' 21 | import { range } from 'lodash' 22 | 23 | export enum LinkType { 24 | Friend, 25 | Collection, 26 | } 27 | 28 | export enum LinkState { 29 | Pass, 30 | Audit, 31 | } 32 | /** 33 | * Link Model also used to valid dto 34 | */ 35 | export class Link extends BaseModel { 36 | @prop({ required: true, trim: true, unique: true }) 37 | @IsString() 38 | /** 39 | * name is site name 40 | */ 41 | name: string 42 | 43 | @prop({ required: true, trim: true, unique: true }) 44 | @IsUrl({ require_protocol: true }) 45 | url: string 46 | 47 | @IsOptional() 48 | @IsUrl({ require_protocol: true }) 49 | @prop({ trim: true }) 50 | avatar?: string 51 | 52 | @IsOptional() 53 | @IsString() 54 | @prop({ trim: true }) 55 | description?: string 56 | 57 | @IsOptional() 58 | @IsEnum(LinkType) 59 | @ApiProperty({ enum: range(0, 1) }) 60 | @prop({ default: LinkType.Friend }) 61 | type?: LinkType 62 | 63 | @IsOptional() 64 | @IsBoolean() 65 | @prop({ default: LinkState.Pass }) 66 | state: LinkState 67 | 68 | @prop() 69 | @IsEmail() 70 | email?: string 71 | get hide() { 72 | return this.state === LinkState.Audit 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /libs/db/src/db.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-18 13:47:30 4 | * @LastEditTime: 2020-10-21 19:32:05 5 | * @LastEditors: Innei 6 | * @FilePath: /server/libs/db/src/db.module.ts 7 | * @MIT 8 | */ 9 | 10 | import Category from './models/category.model' 11 | import Comment from './models/comment.model' 12 | import Note from './models/note.model' 13 | import Page from './models/page.model' 14 | import Post from './models/post.model' 15 | import { DbService } from './db.service' 16 | import { File } from './models/file.model' 17 | import { Global, Module } from '@nestjs/common' 18 | import { Option } from './models/option.model' 19 | import { Project } from './models/project.model' 20 | import { TypegooseModule } from 'nestjs-typegoose' 21 | import { User } from './models/user.model' 22 | import { Say } from './models/say.model' 23 | import { Link } from './models/link.model' 24 | import { Analyze } from './models/analyze.model' 25 | import { Recently } from './models/recently.model' 26 | 27 | const models = TypegooseModule.forFeature([ 28 | Analyze, 29 | Category, 30 | Comment, 31 | File, 32 | Link, 33 | Note, 34 | Option, 35 | Page, 36 | Post, 37 | Project, 38 | Recently, 39 | Say, 40 | User, 41 | ]) 42 | 43 | @Global() 44 | @Module({ 45 | imports: [ 46 | TypegooseModule.forRootAsync({ 47 | useFactory: () => ({ 48 | uri: (process.env.DB_URL || 'mongodb://localhost') + '/mx-space', 49 | useCreateIndex: true, 50 | useFindAndModify: false, 51 | useNewUrlParser: true, 52 | useUnifiedTopology: true, 53 | autoIndex: true, 54 | }), 55 | }), 56 | models, 57 | ], 58 | providers: [DbService], 59 | exports: [DbService, models], 60 | }) 61 | export class DbModule {} 62 | -------------------------------------------------------------------------------- /apps/server/src/auth/roles.guard.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-11-24 16:20:37 4 | * @LastEditTime: 2021-03-21 18:13:17 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/auth/roles.guard.ts 7 | * Mark: Coding with Love 8 | */ 9 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' 10 | import { GqlExecutionContext } from '@nestjs/graphql' 11 | import { AuthGuard } from '@nestjs/passport' 12 | import { IncomingMessage } from 'http' 13 | 14 | /** 15 | * 区分游客和主人的守卫 16 | */ 17 | 18 | declare interface Request { 19 | [name: string]: any 20 | } 21 | @Injectable() 22 | export class RolesGuard extends AuthGuard('jwt') implements CanActivate { 23 | async canActivate(context: ExecutionContext): Promise { 24 | const request: Request = context.switchToHttp().getRequest() 25 | let isMaster = false 26 | if (request.headers['authorization']) { 27 | try { 28 | isMaster = (await super.canActivate(context)) as boolean 29 | } catch {} 30 | } 31 | request.isGuest = !isMaster 32 | request.isMaster = isMaster 33 | return true 34 | } 35 | } 36 | 37 | @Injectable() 38 | export class RolesGQLGuard extends AuthGuard('jwt') implements CanActivate { 39 | async canActivate(context: ExecutionContext): Promise { 40 | const ctx = GqlExecutionContext.create(context) 41 | const request: IncomingMessage = ctx.getContext().req 42 | 43 | let isMaster = false 44 | if (request.headers['authorization']) { 45 | try { 46 | isMaster = (await super.canActivate(context)) as boolean 47 | } catch {} 48 | } 49 | // @ts-ignore 50 | request.isGuest = !isMaster 51 | // @ts-ignore 52 | request.isMaster = isMaster 53 | 54 | return true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql/args/id.input.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-01 15:45:04 4 | * @LastEditTime: 2021-01-15 13:56:00 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/graphql/src/graphql/args/id.input.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { ArgsType, Field, ID, Int, registerEnumType } from '@nestjs/graphql' 10 | import { Transform } from 'class-transformer' 11 | import { 12 | IsEnum, 13 | IsInt, 14 | IsMongoId, 15 | IsNotEmpty, 16 | IsOptional, 17 | Max, 18 | Min, 19 | ValidateIf, 20 | } from 'class-validator' 21 | 22 | @ArgsType() 23 | export class IdInputArgsDto { 24 | @IsMongoId() 25 | @IsNotEmpty() 26 | @Field(() => ID) 27 | id: string 28 | } 29 | @ArgsType() 30 | export class IdInputArgsDtoOptional { 31 | @IsMongoId() 32 | @IsOptional() 33 | id?: string 34 | } 35 | @ArgsType() 36 | export class PagerArgsDto { 37 | @IsInt() 38 | @Min(1) 39 | @Max(20) 40 | @IsOptional() 41 | @Field(() => Int) 42 | size?: number 43 | 44 | @IsInt() 45 | @Min(1) 46 | @IsOptional() 47 | @Field(() => Int) 48 | page?: number 49 | 50 | @IsInt() 51 | @IsOptional() 52 | @Field(() => Int) 53 | state?: number 54 | 55 | @IsOptional() 56 | @IsEnum(['categoryId', 'title', 'created', 'modified']) 57 | @Transform(({ value: v }) => (v === 'category' ? 'categoryId' : v)) 58 | sortBy?: string 59 | 60 | @IsOptional() 61 | @IsEnum([1, -1]) 62 | @ValidateIf((o) => o.sortBy) 63 | @Transform(({ value: v }) => v | 0) 64 | @Field(() => SortOrder) 65 | sortOrder?: SortOrder 66 | 67 | @IsOptional() 68 | @IsInt() 69 | @Field(() => Int) 70 | year?: number 71 | } 72 | 73 | export enum SortOrder { 74 | DESC = -1, 75 | ASC = 1, 76 | } 77 | 78 | registerEnumType(SortOrder, { 79 | name: 'SortOrder', 80 | }) 81 | -------------------------------------------------------------------------------- /apps/server/src/shared/base/dto/pager.dto.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-06-06 18:28:53 4 | * @LastEditTime: 2020-07-12 11:06:06 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/shared/base/dto/pager.dto.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { ApiProperty } from '@nestjs/swagger' 11 | import { Expose, Transform } from 'class-transformer' 12 | import { 13 | IsInt, 14 | IsMongoId, 15 | IsNotEmpty, 16 | IsOptional, 17 | IsString, 18 | Max, 19 | Min, 20 | ValidateIf, 21 | } from 'class-validator' 22 | 23 | export class PagerDto { 24 | @Min(1) 25 | @Max(50) 26 | @IsInt() 27 | @Expose() 28 | @Transform(({ value: val }) => (val ? parseInt(val) : 10), { 29 | toClassOnly: true, 30 | }) 31 | @ApiProperty({ example: 10 }) 32 | size: number 33 | 34 | @Transform(({ value: val }) => (val ? parseInt(val) : 1), { 35 | toClassOnly: true, 36 | }) 37 | @Min(1) 38 | @IsInt() 39 | @Expose() 40 | @ApiProperty({ example: 1 }) 41 | page: number 42 | 43 | @IsOptional() 44 | @IsString() 45 | @IsNotEmpty() 46 | @ApiProperty({ required: false }) 47 | select?: string 48 | 49 | @IsOptional() 50 | @Transform(({ value: val }) => parseInt(val)) 51 | @Min(1) 52 | @IsInt() 53 | @ApiProperty({ example: 2020 }) 54 | year?: number 55 | 56 | @IsOptional() 57 | @Transform(({ value: val }) => parseInt(val)) 58 | @IsInt() 59 | state?: number 60 | } 61 | 62 | export class OffsetDto { 63 | @IsMongoId() 64 | @IsOptional() 65 | before?: string 66 | 67 | @IsMongoId() 68 | @IsOptional() 69 | @ValidateIf((o) => { 70 | return typeof o.before !== 'undefined' 71 | }) 72 | after?: string 73 | 74 | @Transform(({ value }) => +value) 75 | @IsInt() 76 | @IsOptional() 77 | @Max(50) 78 | size?: number 79 | } 80 | -------------------------------------------------------------------------------- /apps/server/src/shared/recently/recently.service.ts: -------------------------------------------------------------------------------- 1 | import { Recently } from '@libs/db/models/recently.model' 2 | import { Injectable } from '@nestjs/common' 3 | import { ReturnModelType } from '@typegoose/typegoose' 4 | import { BaseService } from 'apps/graphql/src/shared/base/base.service' 5 | import { InjectModel } from 'nestjs-typegoose' 6 | import { RecentlyDto } from './recently.dto' 7 | 8 | @Injectable() 9 | export class RecentlyService extends BaseService { 10 | constructor( 11 | @InjectModel(Recently) 12 | private readonly model: ReturnModelType, 13 | ) { 14 | super(model) 15 | } 16 | 17 | async getOffset({ 18 | before, 19 | size, 20 | after, 21 | }: { 22 | before?: string 23 | size?: number 24 | after?: string 25 | }) { 26 | size = size ?? 10 27 | 28 | return await this.model 29 | .find( 30 | after 31 | ? { 32 | _id: { 33 | $gt: after, 34 | }, 35 | } 36 | : before 37 | ? { _id: { $lt: before } } 38 | : {}, 39 | ) 40 | .limit(size) 41 | .sort({ _id: -1 }) 42 | .lean() 43 | } 44 | async getLatestOne() { 45 | return await this.model.findOne().sort({ created: -1 }).lean() 46 | } 47 | 48 | // @ts-ignore 49 | async create(model: RecentlyDto) { 50 | return await this.model.create({ 51 | content: model.content, 52 | language: model.language, 53 | project: model.project, 54 | }) 55 | } 56 | 57 | // @ts-ignore 58 | async delete(id: string) { 59 | try { 60 | const { deletedCount } = await this.model.deleteOne({ 61 | _id: id, 62 | }) 63 | 64 | return deletedCount > 0 65 | } catch { 66 | return false 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql/models/post.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-01 14:17:38 4 | * @LastEditTime: 2020-10-01 21:19:31 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/graphql/models/post.model.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { TextImageRecordType } from '@libs/db/models/base.model' 10 | import Post from '@libs/db/models/post.model' 11 | import { Field, ObjectType } from '@nestjs/graphql' 12 | import { 13 | BaseGLModel, 14 | ImageRecordModel, 15 | PagerModel, 16 | PagerModelImplements, 17 | PostItemCount, 18 | } from './base.model' 19 | import { CategoryItemModel } from './category.model' 20 | @ObjectType() 21 | export class PostItemModel extends BaseGLModel implements Post { 22 | public readonly title: string 23 | 24 | public readonly slug: string 25 | 26 | public readonly text: string 27 | 28 | public readonly allowComment: boolean 29 | 30 | @Field(() => CategoryItemModel) 31 | public readonly category: CategoryItemModel 32 | 33 | // @ts-ignore 34 | public readonly categoryId: string 35 | 36 | public readonly commentsIndex: number 37 | 38 | public readonly copyright: boolean 39 | 40 | @Field(() => PostItemCount) 41 | public readonly count: PostItemCount 42 | 43 | public readonly hide: boolean 44 | 45 | @Field(() => [ImageRecordModel], { nullable: true }) 46 | public readonly images?: TextImageRecordType[] 47 | 48 | public readonly summary?: string 49 | 50 | @Field(() => [String], { nullable: true }) 51 | public readonly tags: string[] 52 | 53 | created: Date 54 | modified: Date 55 | } 56 | 57 | @ObjectType() 58 | export class PostPagerModel implements PagerModelImplements { 59 | @Field(() => PagerModel) 60 | pager: PagerModel 61 | @Field(() => [PostItemModel]) 62 | data: PostItemModel[] 63 | } 64 | -------------------------------------------------------------------------------- /libs/db/src/models/base.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | modelOptions, 3 | plugin, 4 | prop, 5 | Severity, 6 | index, 7 | } from '@typegoose/typegoose' 8 | import * as uniqueValidator from 'mongoose-unique-validator' 9 | import * as mongooseLeanVirtuals from 'mongoose-lean-virtuals' 10 | import { IsString, IsNotEmpty } from 'class-validator' 11 | @modelOptions({ 12 | schemaOptions: { _id: false }, 13 | }) 14 | class Image { 15 | @prop() 16 | width?: number 17 | 18 | @prop() 19 | height?: number 20 | 21 | @prop() 22 | accent?: string 23 | 24 | @prop() 25 | type?: string 26 | 27 | @prop() 28 | src: string 29 | } 30 | 31 | export type { Image as TextImageRecordType } 32 | @plugin(mongooseLeanVirtuals) 33 | @plugin(uniqueValidator) 34 | @modelOptions({ 35 | schemaOptions: { 36 | timestamps: { 37 | createdAt: 'created', 38 | updatedAt: false, 39 | }, 40 | toJSON: { 41 | versionKey: false, 42 | virtuals: true, 43 | }, 44 | toObject: { 45 | versionKey: false, 46 | virtuals: true, 47 | }, 48 | }, 49 | options: { allowMixed: Severity.ALLOW }, 50 | }) 51 | @index({ created: -1 }) 52 | export abstract class BaseModel { 53 | created?: Date 54 | } 55 | 56 | export abstract class BaseCommentIndexModel extends BaseModel { 57 | @prop({ default: 0 }) 58 | commentsIndex?: number 59 | 60 | @prop({ default: true }) 61 | allowComment: boolean 62 | } 63 | 64 | export abstract class WriteBaseModel extends BaseCommentIndexModel { 65 | @prop({ trim: true, index: true, required: true }) 66 | @IsString() 67 | @IsNotEmpty() 68 | title: string 69 | 70 | @prop({ trim: true }) 71 | @IsString() 72 | text: string 73 | 74 | @prop({ type: Image }) 75 | images?: Image[] 76 | 77 | @prop({ default: () => new Date() }) 78 | modified: Date 79 | } 80 | -------------------------------------------------------------------------------- /libs/db/src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-26 11:19:25 4 | * @LastEditTime: 2020-05-31 12:31:17 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/libs/db/src/models/user.model.ts 7 | * @Copyright 8 | */ 9 | 10 | import { DocumentType, prop } from '@typegoose/typegoose' 11 | import { hashSync } from 'bcrypt' 12 | import { Schema } from 'mongoose' 13 | import { BaseModel } from './base.model' 14 | 15 | export type UserDocument = DocumentType 16 | 17 | export class OAuthModel { 18 | @prop() 19 | platform: string 20 | @prop() 21 | id: string 22 | } 23 | 24 | export class TokenModel { 25 | @prop() 26 | created: Date 27 | 28 | @prop() 29 | token: string 30 | 31 | @prop() 32 | expired?: Date 33 | 34 | @prop({ unique: true }) 35 | name: string 36 | } 37 | 38 | export class User extends BaseModel { 39 | @prop({ required: true, unique: true, trim: true }) 40 | username!: string 41 | 42 | @prop({ trim: true }) 43 | name!: string 44 | 45 | @prop() 46 | introduce?: string 47 | 48 | @prop() 49 | avatar?: string 50 | 51 | @prop({ 52 | select: false, 53 | get(val) { 54 | return val 55 | }, 56 | set(val) { 57 | return hashSync(val, 6) 58 | }, 59 | required: true, 60 | }) 61 | password!: string 62 | 63 | @prop() 64 | mail?: string 65 | 66 | @prop() 67 | url?: string 68 | 69 | @prop() 70 | lastLoginTime?: Date 71 | 72 | @prop({ select: false }) 73 | lastLoginIp?: string 74 | 75 | @prop({ type: Schema.Types.Mixed }) 76 | socialIds?: any 77 | 78 | @prop({ select: true, required: true }) 79 | authCode!: string 80 | 81 | @prop({ type: TokenModel, select: false }) 82 | apiToken?: TokenModel[] 83 | 84 | @prop({ type: OAuthModel, select: false }) 85 | oauth2?: OAuthModel[] 86 | } 87 | -------------------------------------------------------------------------------- /libs/db/src/models/page.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-12-29 19:46:49 4 | * @LastEditTime: 2021-02-05 11:39:08 5 | * @LastEditors: Innei 6 | * @FilePath: /server/libs/db/src/models/page.model.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { ApiProperty } from '@nestjs/swagger' 10 | import { prop } from '@typegoose/typegoose' 11 | import { IsNilOrString } from 'utils/validator-decorators/isNilOrString' 12 | import { 13 | IsEnum, 14 | IsInt, 15 | IsNotEmpty, 16 | IsObject, 17 | IsOptional, 18 | IsString, 19 | Min, 20 | } from 'class-validator' 21 | import { Schema } from 'mongoose' 22 | 23 | import { WriteBaseModel } from './base.model' 24 | import { Transform } from 'class-transformer' 25 | 26 | export const pageType = ['md', 'html', 'frame'] 27 | 28 | export default class Page extends WriteBaseModel { 29 | @ApiProperty({ description: 'Slug', required: true }) 30 | @prop({ trim: 1, index: true, required: true, unique: true }) 31 | @IsString() 32 | @IsNotEmpty() 33 | slug!: string 34 | 35 | @ApiProperty({ description: 'SubTitle', required: false }) 36 | @prop({ trim: true }) 37 | @IsString() 38 | @IsOptional() 39 | @IsNilOrString() 40 | subtitle?: string | null 41 | 42 | @ApiProperty({ description: 'Order', required: false }) 43 | @prop({ default: 1 }) 44 | @IsInt() 45 | @Min(0) 46 | @IsOptional() 47 | @Transform(({ value }) => parseInt(value)) 48 | order!: number 49 | 50 | @ApiProperty({ 51 | description: 'Type (MD | html | frame)', 52 | enum: pageType, 53 | required: false, 54 | }) 55 | @prop({ default: 'md' }) 56 | @IsEnum(pageType) 57 | @IsOptional() 58 | type?: string 59 | 60 | @ApiProperty({ description: 'Other Options', required: false }) 61 | @prop({ type: Schema.Types.Mixed }) 62 | @IsOptional() 63 | @IsObject() 64 | options?: Record 65 | } 66 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql/models/note.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-02 14:29:06 4 | * @LastEditTime: 2020-10-02 15:28:43 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/graphql/models/note.model.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { TextImageRecordType } from '@libs/db/models/base.model' 10 | import Note, { NoteMusic } from '@libs/db/models/note.model' 11 | 12 | import { Field, ID, Int, ObjectType } from '@nestjs/graphql' 13 | import { 14 | ImageRecordModel, 15 | PagerModel, 16 | PagerModelImplements, 17 | } from './base.model' 18 | 19 | @ObjectType() 20 | class NoteItemCount { 21 | @Field(() => Int) 22 | read?: number 23 | 24 | @Field(() => Int) 25 | like?: number 26 | } 27 | 28 | @ObjectType() 29 | export class NoteItemModel implements Note { 30 | allowComment: boolean 31 | 32 | @Field(() => ID) 33 | _id: string 34 | 35 | @Field(() => Int) 36 | commentsIndex?: number 37 | 38 | @Field(() => NoteItemCount) 39 | count: NoteItemCount 40 | 41 | created: Date 42 | 43 | modified: Date 44 | 45 | hide: boolean 46 | 47 | @Field(() => [ImageRecordModel], { nullable: true }) 48 | public readonly images?: TextImageRecordType[] 49 | mood?: string 50 | weather?: string 51 | @Field(() => Int) 52 | nid: number 53 | text: string 54 | title: string 55 | 56 | @Field(() => [NoteMusicModel], { nullable: true }) 57 | music?: NoteMusicModel[] 58 | } 59 | 60 | @ObjectType() 61 | export class NoteItemAggregateModel { 62 | data: NoteItemModel 63 | prev?: NoteItemModel 64 | next?: NoteItemModel 65 | } 66 | 67 | @ObjectType() 68 | export class NotePagerModel implements PagerModelImplements { 69 | data: NoteItemModel[] 70 | pager: PagerModel 71 | } 72 | 73 | @ObjectType() 74 | export class NoteMusicModel implements NoteMusic { 75 | @Field() 76 | id: string 77 | @Field() 78 | type: string 79 | } 80 | -------------------------------------------------------------------------------- /apps/server/src/shared/says/says.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-09-27 22:52:03 5 | * @LastEditors: Innei 6 | * @FilePath: /server/src/shared/says/says.controller.ts 7 | * @Copyright 8 | */ 9 | 10 | import { Say } from '@libs/db/models/say.model' 11 | import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common' 12 | import { ApiTags } from '@nestjs/swagger' 13 | import { sample } from 'lodash' 14 | import { CannotFindException } from 'shared/core/exceptions/cant-find.exception' 15 | import { BaseCrud } from 'apps/server/src/shared/base/base.controller' 16 | import { SaysService } from 'apps/server/src/shared/says/says.service' 17 | import { Auth } from '../../../../../shared/core/decorators/auth.decorator' 18 | import { EventTypes } from '../../gateway/events.types' 19 | import { WebEventsGateway } from '../../gateway/web/events.gateway' 20 | import { MongoIdDto } from '../base/dto/id.dto' 21 | 22 | @Controller('says') 23 | @ApiTags('Says Routes') 24 | export class SaysController extends BaseCrud { 25 | constructor( 26 | private readonly service: SaysService, 27 | private readonly webgateway: WebEventsGateway, 28 | ) { 29 | super(service) 30 | } 31 | 32 | @Get('random') 33 | async getRandomOne() { 34 | const res = await this.service.find({}) 35 | if (!res.length) { 36 | throw new CannotFindException() 37 | } 38 | return sample(res) 39 | } 40 | 41 | @Post() 42 | @Auth() 43 | async post(@Body() body: Partial) { 44 | const r = await super.post(body) 45 | this.webgateway.broadcast(EventTypes.SAY_CREATE, r) 46 | return r 47 | } 48 | 49 | @Delete(':id') 50 | @Auth() 51 | async delete(@Param() params: MongoIdDto) { 52 | await super.delete(params) 53 | this.webgateway.broadcast(EventTypes.SAY_DELETE, params.id) 54 | return 'OK' 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/server/src/master/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { 3 | IsEmail, 4 | IsNotEmpty, 5 | IsObject, 6 | IsOptional, 7 | IsString, 8 | IsUrl, 9 | } from 'class-validator' 10 | 11 | class UserOptionDto { 12 | @IsOptional() 13 | @IsString() 14 | @IsNotEmpty() 15 | @ApiProperty({ example: '我是练习时长两年半的个人练习生' }) 16 | readonly introduce?: string 17 | 18 | @ApiProperty({ required: false, example: 'example@example.com' }) 19 | @IsEmail() 20 | @IsOptional() 21 | readonly mail?: string 22 | 23 | @ApiProperty({ required: false, example: 'http://example.com' }) 24 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 25 | @IsOptional() 26 | readonly url?: string 27 | 28 | @ApiProperty({ required: false }) 29 | @IsString() 30 | @IsOptional() 31 | name?: string 32 | 33 | @ApiProperty({ required: false }) 34 | @IsUrl({ require_protocol: true }) 35 | @IsOptional() 36 | readonly avatar?: string 37 | 38 | @IsOptional() 39 | @IsObject() 40 | @ApiProperty({ description: '各种社交 id 记录' }) 41 | readonly socialIds?: Record 42 | } 43 | 44 | export class UserDto extends UserOptionDto { 45 | @ApiProperty() 46 | @IsString() 47 | @IsNotEmpty() 48 | readonly username: string 49 | 50 | @IsString() 51 | @ApiProperty() 52 | @IsNotEmpty() 53 | readonly password: string 54 | } 55 | 56 | export class LoginDto { 57 | @ApiProperty({ required: true }) 58 | @IsString() 59 | username: string 60 | 61 | @ApiProperty({ required: true }) 62 | @IsString() 63 | password: string 64 | } 65 | 66 | export class UserPatchDto extends UserOptionDto { 67 | @ApiProperty({ required: false }) 68 | @IsString() 69 | @IsNotEmpty() 70 | @IsOptional() 71 | readonly username: string 72 | 73 | @IsString() 74 | @ApiProperty({ required: false }) 75 | @IsNotEmpty() 76 | @IsOptional() 77 | readonly password: string 78 | } 79 | -------------------------------------------------------------------------------- /apps/server/src/shared/links/links.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-06-05 21:26:33 4 | * @LastEditTime: 2021-01-17 20:28:49 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/shared/links/links.controller.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { Link } from '@libs/db/models/link.model' 11 | import { 12 | Body, 13 | Controller, 14 | Get, 15 | Param, 16 | Patch, 17 | Post, 18 | Query, 19 | UseGuards, 20 | UseInterceptors, 21 | } from '@nestjs/common' 22 | import { ApiTags } from '@nestjs/swagger' 23 | import { IsString } from 'class-validator' 24 | import { Auth } from 'core/decorators/auth.decorator' 25 | import { PermissionInterceptor } from '../../../../../shared/core/interceptors/permission.interceptors' 26 | import { RolesGuard } from '../../auth/roles.guard' 27 | import { BaseCrud } from '../base/base.controller' 28 | import { LinksService } from './links.service' 29 | 30 | class LinkQueryDto { 31 | @IsString() 32 | author: string 33 | } 34 | 35 | @Controller('links') 36 | @ApiTags('Link Routes') 37 | @UseInterceptors(PermissionInterceptor) 38 | @UseGuards(RolesGuard) 39 | export class LinksController extends BaseCrud { 40 | constructor(private readonly service: LinksService) { 41 | super(service) 42 | } 43 | 44 | @Get('state') 45 | @Auth() 46 | async getLinkCount() { 47 | return await this.service.getCount() 48 | } 49 | 50 | @Post('audit') 51 | async applyForLink(@Body() body: Link, @Query() query: LinkQueryDto) { 52 | await this.service.applyForLink(body) 53 | this.service.sendToMaster(query.author, body) 54 | 55 | return 'OK' 56 | } 57 | 58 | @Patch('audit/:id') 59 | @Auth() 60 | async approveLink(@Param('id') id: string) { 61 | const doc = await this.service.approveLink(id) 62 | 63 | if (doc.email) { 64 | this.service.sendToCandidate(doc) 65 | } 66 | return 'OK' 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /libs/db/src/models/project.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-11 21:58:24 4 | * @LastEditTime: 2020-06-07 14:21:53 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/libs/db/src/models/project.model.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { BaseModel } from '@libs/db/models/base.model' 11 | import { prop } from '@typegoose/typegoose' 12 | import { IsOptional, IsString, IsUrl, isURL } from 'class-validator' 13 | 14 | const validateURL = { 15 | message: '请更正为正确的网址', 16 | validator: (v: string | Array): boolean => { 17 | if (!v) { 18 | return true 19 | } 20 | if (Array.isArray(v)) { 21 | return v.every((url) => isURL(url, { require_protocol: true })) 22 | } 23 | if (!isURL(v, { require_protocol: true })) { 24 | return false 25 | } 26 | }, 27 | } 28 | export class Project extends BaseModel { 29 | @prop({ required: true, unique: true }) 30 | @IsString() 31 | name: string 32 | 33 | @prop({ 34 | validate: validateURL, 35 | }) 36 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 37 | @IsOptional() 38 | previewUrl?: string 39 | 40 | @prop({ 41 | validate: validateURL, 42 | }) 43 | @IsOptional() 44 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 45 | docUrl?: string 46 | 47 | @prop({ 48 | validate: validateURL, 49 | }) 50 | @IsOptional() 51 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 52 | projectUrl?: string 53 | 54 | @IsUrl({ require_protocol: true }, { each: true }) 55 | @IsOptional() 56 | @prop({ 57 | type: String, 58 | validate: validateURL, 59 | }) 60 | images?: string[] 61 | 62 | @prop({ required: true }) 63 | @IsString() 64 | @IsOptional() 65 | description?: string 66 | 67 | @prop({ 68 | validate: validateURL, 69 | }) 70 | @IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' }) 71 | @IsOptional() 72 | avatar?: string 73 | 74 | @prop() 75 | @IsString() 76 | text: string 77 | } 78 | -------------------------------------------------------------------------------- /shared/core/filters/any-exception.filter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-08 20:01:58 4 | * @LastEditTime: 2020-09-06 11:20:09 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/core/filters/any-exception.filter.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { 11 | ArgumentsHost, 12 | Catch, 13 | ExceptionFilter, 14 | HttpException, 15 | HttpStatus, 16 | Logger, 17 | } from '@nestjs/common' 18 | import { FastifyReply, FastifyRequest } from 'fastify' 19 | import { getIp } from '../../utils/ip' 20 | type myError = { 21 | readonly status: number 22 | readonly statusCode?: number 23 | 24 | readonly message?: string 25 | } 26 | 27 | @Catch() 28 | export class AllExceptionsFilter implements ExceptionFilter { 29 | private readonly logger = new Logger('捕获异常') 30 | catch(exception: unknown, host: ArgumentsHost) { 31 | // super.catch(exception, host) 32 | const ctx = host.switchToHttp() 33 | const response = ctx.getResponse() 34 | const request = ctx.getRequest() 35 | 36 | const status = 37 | exception instanceof HttpException 38 | ? exception.getStatus() 39 | : (exception as myError)?.status || 40 | (exception as myError)?.statusCode || 41 | HttpStatus.INTERNAL_SERVER_ERROR 42 | if (process.env.NODE_ENV === 'development') { 43 | console.error(exception) 44 | } else { 45 | const ip = getIp(request) 46 | this.logger.warn( 47 | `IP: ${ip} 错误信息: (${status}) ${ 48 | (exception as any)?.response?.message || 49 | (exception as myError)?.message || 50 | '' 51 | } Path: ${decodeURI(request.raw.url)}`, 52 | ) 53 | } 54 | 55 | response.status(status).send({ 56 | ok: 0, 57 | statusCode: status, 58 | message: 59 | (exception as any)?.response?.message || 60 | (exception as any)?.message || 61 | '未知错误', 62 | // timestamp: new Date().toISOString(), 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /shared/utils/text-base.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentType } from '@typegoose/typegoose' 2 | import { RedisService } from 'nestjs-redis' 3 | import { RedisNames } from '@libs/common/redis/redis.types' 4 | import { WriteBaseModel } from '@libs/db/models/base.model' 5 | import { Cache } from 'cache-manager' 6 | import { CACHE_KEY_PREFIX, CacheKeys } from 'shared/constants' 7 | /* 8 | * @Author: Innei 9 | * @Date: 2020-06-03 12:14:54 10 | * @LastEditTime: 2021-03-21 20:46:09 11 | * @LastEditors: Innei 12 | * @FilePath: /server/shared/utils/text-base.ts 13 | * @Coding with Love 14 | */ 15 | 16 | export type IpLikesMap = { 17 | ip: string 18 | 19 | created: string 20 | } 21 | 22 | export async function updateReadCount< 23 | T extends WriteBaseModel, 24 | U extends { redis: RedisService } 25 | >(this: U, doc: DocumentType, ip?: string) { 26 | const redis = this.redis.getClient(RedisNames.Read) 27 | 28 | const isReadBefore = await redis.sismember(doc._id, ip) 29 | if (isReadBefore) { 30 | return 31 | } 32 | await redis.sadd(doc._id, ip) 33 | await doc.updateOne({ $inc: { 'count.read': 1 } }) 34 | } 35 | 36 | /** 37 | * 之前 like 过的话 return true, 反之 false 38 | * @param this 39 | * @param doc 40 | * @param ip 41 | * @returns 42 | */ 43 | export async function updateLikeCount< 44 | T extends WriteBaseModel, 45 | U extends { redis: RedisService } 46 | >(this: U, doc: DocumentType, ip?: string) { 47 | const redis = this.redis.getClient(RedisNames.Like) 48 | 49 | const isLikedBefore = await redis.sismember(doc._id, ip) 50 | if (isLikedBefore) { 51 | return true 52 | } 53 | await redis.sadd(doc._id, ip) 54 | await doc.updateOne({ $inc: { 'count.like': 1 } }) 55 | return false 56 | } 57 | 58 | export async function refreshKeyedCache(cacheManager: Cache) { 59 | const namedKeyPrefix = CACHE_KEY_PREFIX + 'name:' 60 | cacheManager.del(namedKeyPrefix + CacheKeys.RSS) 61 | cacheManager.del(namedKeyPrefix + CacheKeys.SiteMapCatch) 62 | cacheManager.del(namedKeyPrefix + CacheKeys.AggregateCatch) 63 | } 64 | -------------------------------------------------------------------------------- /libs/db/src/models/note.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2021-01-01 13:25:04 4 | * @LastEditTime: 2021-03-12 11:13:52 5 | * @LastEditors: Innei 6 | * @FilePath: /server/libs/db/src/models/note.model.ts 7 | * Mark: Coding with Love 8 | */ 9 | import { AutoIncrementID } from '@typegoose/auto-increment' 10 | import { index, modelOptions, plugin, prop } from '@typegoose/typegoose' 11 | import { IsNumber } from 'class-validator' 12 | import * as uniqueValidator from 'mongoose-unique-validator' 13 | import { WriteBaseModel } from './base.model' 14 | 15 | @modelOptions({ schemaOptions: { id: false, _id: false } }) 16 | export class Coordinate { 17 | @IsNumber() 18 | @prop() 19 | latitude: number 20 | @prop() 21 | @IsNumber() 22 | longitude: number 23 | } 24 | 25 | export class Count { 26 | @prop({ default: 0 }) 27 | read?: number 28 | 29 | @prop({ default: 0 }) 30 | like?: number 31 | } 32 | 33 | @modelOptions({ 34 | schemaOptions: { 35 | id: false, 36 | _id: false, 37 | }, 38 | }) 39 | export class NoteMusic { 40 | @prop({ required: true }) 41 | type: string 42 | @prop({ required: true }) 43 | id: string 44 | } 45 | 46 | @plugin(AutoIncrementID, { 47 | field: 'nid', 48 | startAt: 1, 49 | }) 50 | @plugin(uniqueValidator) 51 | @index([{ text: 'text' }, { modified: -1 }, { nid: -1 }]) 52 | export default class Note extends WriteBaseModel { 53 | @prop({ required: false, unique: true }) 54 | public nid: number 55 | 56 | @prop({ default: false }) 57 | hide: boolean 58 | 59 | @prop({ 60 | select: false, 61 | }) 62 | password?: string 63 | 64 | @prop() 65 | secret?: Date 66 | 67 | @prop() 68 | mood?: string 69 | 70 | @prop() 71 | weather?: string 72 | 73 | @prop() 74 | hasMemory?: boolean 75 | 76 | @prop({ select: false, type: Coordinate }) 77 | coordinates?: Coordinate 78 | 79 | @prop({ select: false }) 80 | location?: string 81 | 82 | @prop({ type: Count, default: { read: 0, like: 0 }, _id: false }) 83 | count?: Count 84 | 85 | @prop({ type: [NoteMusic] }) 86 | music?: NoteMusic[] 87 | } 88 | -------------------------------------------------------------------------------- /apps/server/src/shared/recently/recently.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Post, 10 | Query, 11 | UnprocessableEntityException, 12 | } from '@nestjs/common' 13 | import { ApiTags } from '@nestjs/swagger' 14 | import { Auth } from 'core/decorators/auth.decorator' 15 | import { EventTypes } from '../../gateway/events.types' 16 | import { WebEventsGateway } from '../../gateway/web/events.gateway' 17 | import { MongoIdDto } from '../base/dto/id.dto' 18 | import { OffsetDto } from '../base/dto/pager.dto' 19 | import { RecentlyDto } from './recently.dto' 20 | import { RecentlyService } from './recently.service' 21 | 22 | @Controller('recently') 23 | @ApiTags('Recently') 24 | export class RecentlyController { 25 | constructor( 26 | private readonly service: RecentlyService, 27 | private readonly gateway: WebEventsGateway, 28 | ) {} 29 | 30 | @Get('latest') 31 | async getLatestOne() { 32 | return await this.service.getLatestOne() 33 | } 34 | 35 | @Get('/') 36 | async getList(@Query() query: OffsetDto) { 37 | const { before, after, size } = query 38 | 39 | if (before && after) { 40 | throw new UnprocessableEntityException('before or after must choice one') 41 | } 42 | 43 | return await this.service.getOffset({ before, after, size }) 44 | } 45 | 46 | @Post('/') 47 | @Auth() 48 | @HttpCode(HttpStatus.CREATED) 49 | async create(@Body() body: RecentlyDto) { 50 | const res = await this.service.create(body) 51 | process.nextTick(() => { 52 | this.gateway.broadcast(EventTypes.RECENTLY_CREATE, res) 53 | }) 54 | return res 55 | } 56 | 57 | @Delete('/:id') 58 | @Auth() 59 | @HttpCode(HttpStatus.NO_CONTENT) 60 | async del(@Param() { id }: MongoIdDto) { 61 | const res = await this.service.delete(id) 62 | if (!res) { 63 | throw new UnprocessableEntityException('删除失败, 条目不存在') 64 | } 65 | process.nextTick(() => { 66 | this.gateway.broadcast(EventTypes.RECENTLY_DElETE, { id }) 67 | }) 68 | return 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/aggregate/aggregate.resolver.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-03 10:14:47 4 | * @LastEditTime: 2020-10-04 09:20:35 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/aggregate/aggregate.resolver.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import Category from '@libs/db/models/category.model' 10 | import { Args, Query, Resolver } from '@nestjs/graphql' 11 | import { ConfigsService } from 'shared/global' 12 | import { yearCondition } from 'shared/utils' 13 | import { pick } from 'lodash' 14 | import { TimelineModel } from '../../graphql/models/aggregate.model' 15 | 16 | import { TimelineArgsDto } from './aggregate.input' 17 | import { AggregateService } from './aggregate.service' 18 | 19 | @Resolver() 20 | export class AggregateResolver { 21 | constructor( 22 | private readonly service: AggregateService, 23 | private readonly configs: ConfigsService, 24 | ) {} 25 | 26 | @Query(() => TimelineModel) 27 | async getTimeline(@Args() args: TimelineArgsDto) { 28 | const { sort = 1, year } = args 29 | const data = {} as any 30 | 31 | const getPosts = async () => { 32 | const data = await this.service.postModel 33 | .find({ hide: false, ...yearCondition(year) }) 34 | .sort({ created: sort }) 35 | .populate('category') 36 | .lean() 37 | 38 | return data.map((item) => ({ 39 | ...pick(item, ['_id', 'title', 'slug', 'created']), 40 | category: item.category, 41 | summary: 42 | item.summary ?? 43 | (item.text.length > 150 44 | ? item.text.slice(0, 150) + '...' 45 | : item.text), 46 | url: encodeURI( 47 | '/posts/' + (item.category as Category).slug + '/' + item.slug, 48 | ), 49 | })) 50 | } 51 | const getNotes = async () => 52 | await this.service.noteModel 53 | .find({ 54 | hide: false, 55 | password: undefined, 56 | ...yearCondition(year), 57 | }) 58 | .sort({ created: sort }) 59 | .select('_id nid title weather mood created') 60 | .lean() 61 | 62 | data.notes = await getNotes() 63 | data.posts = await getPosts() 64 | 65 | return { ...data } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /shared/core/interceptors/permission.interceptors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-07-12 13:46:47 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/core/interceptors/permission.interceptors.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { 11 | CallHandler, 12 | ExecutionContext, 13 | Injectable, 14 | NestInterceptor, 15 | UnauthorizedException, 16 | } from '@nestjs/common' 17 | import { GqlExecutionContext } from '@nestjs/graphql' 18 | import { AnyType } from 'apps/server/src/shared/base/interfaces' 19 | import { IncomingMessage } from 'http' 20 | import { isObjectLike } from 'lodash' 21 | import { Observable } from 'rxjs' 22 | import { map } from 'rxjs/operators' 23 | 24 | @Injectable() 25 | export class PermissionInterceptor implements NestInterceptor { 26 | intercept(context: ExecutionContext, next: CallHandler): Observable { 27 | const http = context.switchToHttp() 28 | const req = http.getRequest() as IncomingMessage 29 | 30 | return handle(next, req) 31 | } 32 | } 33 | 34 | @Injectable() 35 | export class PermissionGQLInterceptor 36 | implements NestInterceptor { 37 | intercept(context: ExecutionContext, next: CallHandler): Observable { 38 | // const http = context.switchToHttp() 39 | const req = GqlExecutionContext.create(context).getContext() 40 | .req as IncomingMessage 41 | 42 | return handle(next, req) 43 | } 44 | } 45 | function handle(next: CallHandler, req: IncomingMessage): Observable { 46 | return next.handle().pipe( 47 | map((data) => { 48 | // @ts-ignore 49 | if (!req.isMaster) { 50 | // data.data is array, because pager structure is { page: {}, data: [{},...] } 51 | if (data && isObjectLike(data) && Array.isArray(data.data)) { 52 | return { 53 | ...data, 54 | data: data.data.filter((i) => i.hide !== true), 55 | } 56 | } else if (Array.isArray(data)) { 57 | return data.filter((i) => i.hide !== true) 58 | } 59 | 60 | if (data && data.hide === true) { 61 | throw new UnauthorizedException('你.是我的主人吗 ಠ_ಠ') 62 | } 63 | } 64 | return data 65 | }), 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2020-10-21 19:27:50 5 | * @LastEditors: Innei 6 | * @FilePath: /server/src/utils/index.ts 7 | * @Coding with Love 8 | */ 9 | 10 | export function addConditionToSeeHideContent(isMaster: boolean) { 11 | return isMaster 12 | ? { 13 | $or: [{ hide: false }, { hide: true }], 14 | } 15 | : { hide: false, password: undefined } 16 | } 17 | 18 | export const range = (min: number, max: number): number[] => { 19 | const arr = [] 20 | for (let index = min; index <= max; index++) { 21 | arr.push(index) 22 | } 23 | return arr 24 | } 25 | 26 | export function getRandomInt(min: number, max: number) { 27 | min = Math.ceil(min) 28 | max = Math.floor(max) 29 | return Math.floor(Math.random() * (max - min)) + min //不含最大值,含最小值 30 | } 31 | export function PickOne(arr: Array): T { 32 | const length = arr.length 33 | const random = getRandomInt(0, length) 34 | return arr[random] 35 | } 36 | 37 | const md5 = (text: string) => 38 | require('crypto').createHash('md5').update(text).digest('hex') 39 | export function getAvatar(mail: string) { 40 | if (!mail) { 41 | return '' 42 | } 43 | return `https://sdn.geekzu.org/avatar/${md5(mail)}` 44 | } 45 | 46 | export const yearCondition = (year?: number) => { 47 | if (!year) { 48 | return {} 49 | } 50 | return { 51 | created: { 52 | $gte: new Date(year, 1, 1), 53 | $lte: new Date(year + 1, 1, 1), 54 | }, 55 | } 56 | } 57 | 58 | export function hasChinese(str: string) { 59 | return escape(str).indexOf('%u') < 0 ? false : true 60 | } 61 | 62 | export const isDev = process.env.NODE_ENV === 'development' 63 | 64 | export const escapeShell = function (cmd: string) { 65 | return '"' + cmd.replace(/(["\s'$`\\])/g, '\\$1') + '"' 66 | } 67 | 68 | export function arrDifference(a1: string[], a2: string[]) { 69 | const a = [], 70 | diff = [] 71 | 72 | for (let i = 0; i < a1.length; i++) { 73 | a[a1[i]] = true 74 | } 75 | 76 | for (let i = 0; i < a2.length; i++) { 77 | if (a[a2[i]]) { 78 | delete a[a2[i]] 79 | } else { 80 | a[a2[i]] = true 81 | } 82 | } 83 | 84 | for (const k in a) { 85 | diff.push(k) 86 | } 87 | 88 | return diff 89 | } 90 | -------------------------------------------------------------------------------- /libs/common/src/common.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-08 17:02:08 4 | * @LastEditTime: 2021-03-21 19:31:13 5 | * @LastEditors: Innei 6 | * @FilePath: /server/libs/common/src/common.module.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { CacheModule, Module, Provider } from '@nestjs/common' 11 | import { ConfigModule } from '@nestjs/config' 12 | import { APP_INTERCEPTOR } from '@nestjs/core' 13 | import * as redisStore from 'cache-manager-redis-store' 14 | import { HttpCacheInterceptor } from 'core/interceptors/http-cache.interceptors' 15 | import { RedisModule } from 'nestjs-redis' 16 | import { RedisNames } from './redis/redis.types' 17 | import { TasksModule } from './tasks/tasks.module' 18 | 19 | const providers: Provider[] = [] 20 | 21 | const CacheProvider = { 22 | provide: APP_INTERCEPTOR, 23 | useClass: HttpCacheInterceptor, 24 | } 25 | 26 | if (process.env.NODE_ENV === 'production') { 27 | providers.push(CacheProvider) 28 | } 29 | // for debug 30 | // providers.push(CacheProvider) 31 | 32 | const CacheModuleDynamic = CacheModule.registerAsync({ 33 | useFactory: () => ({ 34 | store: redisStore, 35 | host: 'localhost', 36 | port: 6379, 37 | ttl: 5, 38 | max: 300, 39 | }), 40 | }) 41 | @Module({ 42 | imports: [ 43 | ConfigModule.forRoot({ 44 | envFilePath: [ 45 | process.env.NODE_ENV === 'production' 46 | ? '.env.production' 47 | : '.env.development', 48 | '.env', 49 | ], 50 | isGlobal: true, 51 | }), 52 | CacheModuleDynamic, 53 | RedisModule.register([ 54 | { 55 | name: RedisNames.Access, 56 | keyPrefix: 'mx_access_', 57 | }, 58 | { 59 | name: RedisNames.Like, 60 | keyPrefix: 'mx_like_', 61 | }, 62 | { 63 | name: RedisNames.Read, 64 | keyPrefix: 'mx_read_', 65 | }, 66 | { 67 | name: RedisNames.LoginRecord, 68 | keyPrefix: 'mx_' + RedisNames.LoginRecord + '_', 69 | }, 70 | { name: RedisNames.MaxOnlineCount, keyPrefix: 'mx_count_' }, 71 | // { name: RedisNames.LikeThisSite, keyPrefix: 'mx_like_site' }, 72 | ]), 73 | TasksModule, 74 | ], 75 | providers, 76 | exports: [CacheModuleDynamic], 77 | }) 78 | export class CommonModule {} 79 | -------------------------------------------------------------------------------- /apps/server/src/shared/uploads/image.service.ts: -------------------------------------------------------------------------------- 1 | import { File, FileLocate, FileType } from '@libs/db/models/file.model' 2 | import { Injectable } from '@nestjs/common' 3 | import { ReturnModelType } from '@typegoose/typegoose' 4 | import { shuffle } from 'lodash' 5 | import { InjectModel } from 'nestjs-typegoose' 6 | import { ConfigsService } from '../../../../../shared/global/configs/configs.service' 7 | import { UploadsService } from './uploads.service' 8 | import Pic = require('picgo') 9 | @Injectable() 10 | export class ImageService { 11 | public rootPath = UploadsService.rootPath 12 | private pic = new Pic() 13 | 14 | constructor( 15 | @InjectModel(File) private readonly model: ReturnModelType, 16 | private readonly configs: ConfigsService, 17 | ) { 18 | this.pic.setConfig({ 19 | picBed: { 20 | current: 'github', 21 | github: { 22 | branch: 'master', 23 | customUrl: configs.imageBed.customUrl, 24 | path: '', 25 | repo: configs.imageBed.repo, 26 | token: configs.imageBed.token, 27 | }, 28 | }, 29 | }) 30 | } 31 | 32 | async getRandomImages(size = 5, type: FileType) { 33 | if (size < 1) { 34 | return [] 35 | } 36 | 37 | const allImages = await this.model.find({ type }).lean() 38 | if (!allImages.length) { 39 | return [] 40 | } 41 | const randomImages = shuffle(allImages) 42 | if (size === 1) { 43 | return randomImages.pop() 44 | } 45 | 46 | if (allImages.length <= size) { 47 | return randomImages 48 | } 49 | 50 | return randomImages.splice(0, size) 51 | } 52 | 53 | async syncToImageBed(files: [{ path: string; name: string }]) { 54 | const res = [] 55 | for await (const file of files) { 56 | await this.pic.upload([file.path]) 57 | 58 | this.pic.output.map(async (pic) => { 59 | if (!pic.imgUrl) { 60 | return console.error('图片上传失败') 61 | } 62 | const imageUrl = pic.imgUrl 63 | await this.model.updateOne( 64 | { name: file.name }, 65 | { 66 | locate: FileLocate.Online, 67 | url: imageUrl, 68 | }, 69 | ) 70 | return imageUrl 71 | }) 72 | } 73 | return res 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /apps/server/src/shared/posts/dto/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2021-01-15 15:05:52 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/shared/posts/dto/index.ts 7 | * @MIT 8 | */ 9 | import { ApiProperty } from '@nestjs/swagger' 10 | import { Transform } from 'class-transformer' 11 | import { 12 | ArrayUnique, 13 | IsBoolean, 14 | IsEnum, 15 | IsMongoId, 16 | IsNotEmpty, 17 | IsNotEmptyObject, 18 | IsOptional, 19 | IsString, 20 | MaxLength, 21 | ValidateIf, 22 | } from 'class-validator' 23 | import { PagerDto } from '../../base/dto/pager.dto' 24 | 25 | export class CategoryAndSlug { 26 | @ApiProperty({ example: 'Z-Turn' }) 27 | @IsString() 28 | readonly category: string 29 | 30 | @IsString() 31 | @ApiProperty({ example: 'why-winserver' }) 32 | @Transform(({ value: v }) => decodeURI(v)) 33 | readonly slug: string 34 | } 35 | 36 | export class PostDto { 37 | @ApiProperty({ example: 'title' }) 38 | @IsString() 39 | @IsNotEmpty() 40 | title: string 41 | 42 | @ApiProperty({ example: 'this is text.' }) 43 | @IsString() 44 | @IsNotEmpty() 45 | text: string 46 | 47 | @ApiProperty() 48 | @IsString() 49 | @IsNotEmpty() 50 | slug: string 51 | 52 | @IsMongoId() 53 | @ApiProperty({ 54 | example: '5e6f67e75b303781d2807278', 55 | }) 56 | categoryId: string 57 | 58 | @ApiProperty({ required: false }) 59 | @IsString() 60 | @IsOptional() 61 | @IsNotEmpty() 62 | @MaxLength(150, { message: '总结的字数不得大于 150 个字符哦' }) 63 | summary: string 64 | 65 | @IsBoolean() 66 | @IsOptional() 67 | @ApiProperty({ example: false }) 68 | hide: boolean 69 | 70 | @IsBoolean() 71 | @IsOptional() 72 | copyright?: boolean 73 | 74 | @IsOptional() 75 | @IsNotEmpty({ each: true }) 76 | @IsString({ each: true }) 77 | @ArrayUnique() 78 | tags: string[] 79 | 80 | @IsOptional() 81 | @IsNotEmptyObject() 82 | options?: Record 83 | } 84 | 85 | export class PostQueryDto extends PagerDto { 86 | @IsOptional() 87 | @IsEnum(['categoryId', 'title', 'created', 'modified']) 88 | @Transform(({ value: v }) => (v === 'category' ? 'categoryId' : v)) 89 | sortBy?: string 90 | 91 | @IsOptional() 92 | @IsEnum([1, -1]) 93 | @ValidateIf((o) => o.sortBy) 94 | @Transform(({ value: v }) => v | 0) 95 | sortOrder?: 1 | -1 96 | } 97 | -------------------------------------------------------------------------------- /apps/graphql/src/graphql/models/base.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-01 14:42:26 4 | * @LastEditTime: 2020-10-01 20:53:30 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/graphql/models/base.model.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { TextImageRecordType } from '@libs/db/models/base.model' 10 | import { Field, ID, Int, ObjectType } from '@nestjs/graphql' 11 | 12 | @ObjectType() 13 | export abstract class BaseGLModel { 14 | @Field(() => ID) 15 | public readonly _id!: string 16 | 17 | @Field() 18 | public readonly created: Date 19 | 20 | @Field() 21 | public readonly modified: Date 22 | } 23 | 24 | @ObjectType() 25 | export class ImageRecordModel implements TextImageRecordType { 26 | height: number 27 | 28 | width: number 29 | 30 | src: string 31 | 32 | type?: string 33 | } 34 | 35 | @ObjectType() 36 | export class BaseTextGLModel extends BaseGLModel { 37 | title: string 38 | 39 | text: string 40 | } 41 | 42 | @ObjectType() 43 | export class TextModelImplementsImageRecordModel extends BaseTextGLModel { 44 | @Field(() => [ImageRecordModel], { nullable: true }) 45 | images?: ImageRecordModel[] 46 | } 47 | 48 | @ObjectType() 49 | export class PostItemCount { 50 | @Field(() => Int) 51 | like?: number 52 | 53 | @Field(() => Int) 54 | read?: number 55 | } 56 | 57 | @ObjectType() 58 | export class PagerModel { 59 | /** 60 | * 总条数 61 | */ 62 | @Field(() => Int) 63 | total: number 64 | /** 65 | * 一页多少条 66 | */ 67 | @Field(() => Int) 68 | size: number 69 | /** 70 | * 当前页 71 | */ 72 | @Field(() => Int) 73 | currentPage: number 74 | /** 75 | * 总页数 76 | */ 77 | @Field(() => Int) 78 | totalPage: number 79 | hasNextPage: boolean 80 | hasPrevPage: boolean 81 | } 82 | 83 | // export const PagerModelFactory = (classRef: Type): any => { 84 | // @ObjectType({ isAbstract: true }) 85 | // abstract class PagerDataModel { 86 | // @Field(() => PagerModel) 87 | // public pager: PagerModel 88 | 89 | // @Field(type => [classRef], { nullable: true }) 90 | // public data: T[] 91 | // } 92 | 93 | // return PagerDataModel 94 | // } 95 | 96 | export interface PagerModelImplements { 97 | pager: PagerModel 98 | data: any 99 | } 100 | -------------------------------------------------------------------------------- /apps/server/src/shared/categories/dto/category.dto.ts: -------------------------------------------------------------------------------- 1 | import { UnprocessableEntityException } from '@nestjs/common' 2 | /* 3 | * @Author: Innei 4 | * @Date: 2020-04-30 12:21:51 5 | * @LastEditTime: 2021-01-15 13:59:11 6 | * @LastEditors: Innei 7 | * @FilePath: /server/apps/server/src/shared/categories/dto/category.dto.ts 8 | * @MIT 9 | */ 10 | import { ApiProperty } from '@nestjs/swagger' 11 | import { Transform } from 'class-transformer' 12 | import { 13 | IsBoolean, 14 | IsEnum, 15 | IsMongoId, 16 | IsNotEmpty, 17 | IsOptional, 18 | IsString, 19 | } from 'class-validator' 20 | import { uniq } from 'lodash' 21 | import { IsBooleanOrString } from 'utils/validator-decorators/isBooleanOrString' 22 | 23 | export enum CategoryType { 24 | Category, 25 | Tag, 26 | } 27 | 28 | export class CategoryDto { 29 | @IsString() 30 | @IsNotEmpty() 31 | @ApiProperty() 32 | name: string 33 | 34 | @IsEnum(CategoryType) 35 | @IsOptional() 36 | @ApiProperty({ enum: [0, 1] }) 37 | type?: CategoryType 38 | 39 | @IsString() 40 | @IsNotEmpty() 41 | @IsOptional() 42 | slug?: string 43 | } 44 | 45 | export class SlugOrIdDto { 46 | @IsString() 47 | @IsNotEmpty() 48 | @ApiProperty() 49 | query?: string 50 | } 51 | 52 | export class MultiQueryTagAndCategoryDto { 53 | @IsOptional() 54 | @Transform(({ value: val }) => { 55 | if (val === '1' || val === 'true') { 56 | return true 57 | } else { 58 | return val 59 | } 60 | }) 61 | @IsBooleanOrString() 62 | tag?: boolean | string 63 | } 64 | export class MultiCategoriesQueryDto { 65 | @IsOptional() 66 | @IsMongoId({ 67 | each: true, 68 | message: '多分类查询使用逗号分隔, 应为 mongoID', 69 | }) 70 | @Transform(({ value: v }) => uniq(v.split(','))) 71 | ids?: Array 72 | 73 | @IsOptional() 74 | @IsBoolean() 75 | @Transform((b) => Boolean(b)) 76 | @ApiProperty({ enum: [1, 0] }) 77 | joint?: boolean 78 | 79 | @IsOptional() 80 | @Transform(({ value: v }: { value: string }) => { 81 | if (typeof v !== 'string') { 82 | throw new UnprocessableEntityException('type must be a string') 83 | } 84 | switch (v.toLowerCase()) { 85 | case 'category': 86 | return CategoryType.Category 87 | case 'tag': 88 | return CategoryType.Tag 89 | default: 90 | return CategoryType.Category 91 | } 92 | }) 93 | type: CategoryType 94 | } 95 | -------------------------------------------------------------------------------- /apps/server/src/shared/page/page.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-17 16:17:04 4 | * @LastEditTime: 2020-06-12 20:02:55 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/shared/page/page.controller.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import Page from '@libs/db/models/page.model' 11 | import { 12 | Body, 13 | Controller, 14 | Delete, 15 | Get, 16 | Param, 17 | Post, 18 | Put, 19 | Query, 20 | UseGuards, 21 | } from '@nestjs/common' 22 | import { AuthGuard } from '@nestjs/passport' 23 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' 24 | import { CannotFindException } from 'shared/core/exceptions/cant-find.exception' 25 | import { MongoIdDto } from 'apps/server/src/shared/base/dto/id.dto' 26 | import { PageService } from './page.service' 27 | 28 | import { PagerDto } from '../base/dto/pager.dto' 29 | 30 | @ApiTags('Page Routes') 31 | @Controller('pages') 32 | export class PageController { 33 | constructor(private readonly service: PageService) {} 34 | 35 | @Get() 36 | async getPagesSummary(@Query() query: PagerDto) { 37 | const { size, select, page } = query 38 | 39 | return await this.service.findWithPaginator( 40 | {}, 41 | { 42 | limit: size, 43 | skip: (page - 1) * size, 44 | select, 45 | }, 46 | ) 47 | } 48 | 49 | @Get(':id') 50 | async getPageById(@Param() params: MongoIdDto) { 51 | const page = this.service.findById(params.id) 52 | if (!page) { 53 | throw new CannotFindException() 54 | } 55 | return page 56 | } 57 | 58 | @Get('slug/:slug') 59 | async getPageBySlug(@Param('slug') slug: string) { 60 | const page = await this.service.findOne({ 61 | slug, 62 | }) 63 | 64 | if (!page) { 65 | throw new CannotFindException() 66 | } 67 | return page 68 | } 69 | 70 | @Post() 71 | @ApiBearerAuth() 72 | @UseGuards(AuthGuard('jwt')) 73 | async createPage(@Body() body: Page) { 74 | const doc = await this.service.createNew(body) 75 | this.service.RecordImageDimensions(doc._id) 76 | return doc 77 | } 78 | 79 | @Put(':id') 80 | @ApiBearerAuth() 81 | @UseGuards(AuthGuard('jwt')) 82 | async modifiedPage(@Body() body: Page, @Param() params: MongoIdDto) { 83 | const { id } = params 84 | const res = await this.service.update({ _id: id }, body) 85 | this.service.RecordImageDimensions(id) 86 | return res 87 | } 88 | 89 | @Delete(':id') 90 | @ApiBearerAuth() 91 | @UseGuards(AuthGuard('jwt')) 92 | async deletePage(@Param() params: MongoIdDto) { 93 | return await this.service.deleteOneAsync({ 94 | _id: params.id, 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/posts/posts.resolver.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-01 13:47:59 4 | * @LastEditTime: 2020-10-02 13:45:39 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/posts/posts.resolver.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import { NotFoundException, UseGuards, UseInterceptors } from '@nestjs/common' 10 | import { Args, Query, Resolver } from '@nestjs/graphql' 11 | import { RolesGQLGuard } from 'apps/server/src/auth/roles.guard' 12 | import { MasterGQL } from 'shared/core/decorators/guest.decorator' 13 | import { PermissionGQLInterceptor } from 'shared/core/interceptors/permission.interceptors' 14 | import { addConditionToSeeHideContent, yearCondition } from 'shared/utils' 15 | import { IdInputArgsDto, PagerArgsDto } from '../../graphql/args/id.input' 16 | import { PostItemModel, PostPagerModel } from '../../graphql/models/post.model' 17 | import { SlugTitleInput } from './posts.input' 18 | import { PostsService } from './posts.service' 19 | 20 | @Resolver() 21 | @UseGuards(RolesGQLGuard) 22 | @UseInterceptors(PermissionGQLInterceptor) 23 | export class PostsResolver { 24 | constructor(private postService: PostsService) {} 25 | 26 | @Query(() => PostItemModel) 27 | public async getPostById(@Args() { id }: IdInputArgsDto) { 28 | return await this.postService.findPostById(id) 29 | } 30 | 31 | @Query(() => PostItemModel) 32 | public async getPostBySlug(@Args() { slug, category }: SlugTitleInput) { 33 | const categoryDocument = await this.postService.getCategoryBySlug(category) 34 | if (!categoryDocument) { 35 | throw new NotFoundException('该分类未找到 (。•́︿•̀。)') 36 | } 37 | const postDocument = await this.postService 38 | .findOne({ 39 | slug, 40 | categoryId: categoryDocument._id, 41 | // ...condition, 42 | }) 43 | .populate('category') 44 | 45 | if (!postDocument) { 46 | throw new NotFoundException('该文章未找到 (。ŏ_ŏ)') 47 | } 48 | 49 | return postDocument 50 | } 51 | 52 | @Query(() => PostPagerModel) 53 | // @Query(() => [PostItemModel]) 54 | public async getPostsWithPager( 55 | @MasterGQL() isMaster: boolean, 56 | @Args() args: PagerArgsDto, 57 | ) { 58 | const { page = 1, size = 10, sortBy, sortOrder, year } = args 59 | 60 | const condition = { 61 | ...addConditionToSeeHideContent(isMaster), 62 | ...yearCondition(year), 63 | } 64 | return await this.postService.findWithPaginator(condition, { 65 | limit: size, 66 | skip: (page - 1) * size, 67 | sort: sortBy ? { [sortBy]: sortOrder || -1 } : { created: -1 }, 68 | populate: 'category', 69 | }) 70 | } 71 | // TODO search 72 | } 73 | -------------------------------------------------------------------------------- /libs/db/src/models/comment.model.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-18 19:04:13 4 | * @LastEditTime: 2020-07-19 14:11:54 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/libs/db/src/models/comment.model.ts 7 | * @Copyright 8 | */ 9 | 10 | import { pre, prop, Ref } from '@typegoose/typegoose' 11 | import { getAvatar } from 'shared/utils' 12 | import { Types } from 'mongoose' 13 | 14 | import { BaseModel } from './base.model' 15 | import Note from './note.model' 16 | import Page from './page.model' 17 | import Post from './post.model' 18 | 19 | function autoPopulateSubs(next: () => void) { 20 | this.populate({ options: { sort: { created: -1 } }, path: 'children' }) 21 | next() 22 | } 23 | 24 | export enum CommentRefTypes { 25 | Post = 'Post', 26 | Note = 'Note', 27 | Page = 'Page', 28 | } 29 | 30 | export enum CommentState { 31 | Unread, 32 | Read, 33 | Junk, 34 | } 35 | 36 | @pre('findOne', autoPopulateSubs) 37 | @pre('find', autoPopulateSubs) 38 | export default class Comment extends BaseModel { 39 | @prop({ refPath: 'refType' }) 40 | ref: Ref 41 | 42 | @prop({ required: true, default: 'Post', enum: CommentRefTypes }) 43 | refType: CommentRefTypes 44 | 45 | @prop({ trim: true, required: true }) 46 | author!: string 47 | 48 | @prop({ trim: true }) 49 | mail?: string 50 | 51 | @prop({ trim: true }) 52 | url?: string 53 | 54 | @prop({ required: true }) 55 | text!: string 56 | 57 | // 0 : 未读 58 | // 1 : 已读 59 | // 2 : 垃圾 60 | @prop({ default: 0 }) 61 | state?: CommentState 62 | 63 | // @prop({ default: false }) 64 | // hasParent?: boolean 65 | // 66 | @prop({ ref: () => Comment }) 67 | parent?: Ref 68 | 69 | @prop({ ref: () => Comment, type: Types.ObjectId }) 70 | children?: Ref[] 71 | 72 | @prop({ default: 1 }) 73 | commentsIndex?: number 74 | @prop() 75 | key?: string 76 | @prop({ select: false }) 77 | ip?: string 78 | 79 | @prop({ select: false }) 80 | agent?: string 81 | 82 | @prop({ 83 | ref: () => Post, 84 | foreignField: '_id', 85 | localField: 'ref', 86 | justOne: true, 87 | }) 88 | public post: Ref 89 | 90 | @prop({ 91 | ref: () => Note, 92 | foreignField: '_id', 93 | localField: 'ref', 94 | justOne: true, 95 | }) 96 | public note: Ref 97 | 98 | @prop({ 99 | ref: () => Page, 100 | foreignField: '_id', 101 | localField: 'ref', 102 | justOne: true, 103 | }) 104 | public page: Ref 105 | 106 | public get avatar() { 107 | return getAvatar(this.mail) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /apps/server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-12 15:52:01 4 | * @LastEditTime: 2021-01-15 14:54:28 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/app.module.ts 7 | * @MIT 8 | */ 9 | 10 | import { CommonModule } from '@libs/common' 11 | import { DbModule } from '@libs/db' 12 | import { 13 | MiddlewareConsumer, 14 | Module, 15 | NestModule, 16 | Provider, 17 | RequestMethod, 18 | ValidationPipe, 19 | } from '@nestjs/common' 20 | import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core' 21 | import { GatewayModule } from 'apps/server/src/gateway/gateway.module' 22 | import { JSONSerializeInterceptor } from 'core/interceptors/response.interceptors' 23 | import { RedisModule } from 'nestjs-redis' 24 | import { SpiderGuard } from 'shared/core/guards/spider.guard' 25 | import { isDev } from 'utils/index' 26 | 27 | import { AnalyzeMiddleware } from '../../../shared/core/middlewares/analyze.middleware' 28 | import { SkipBrowserDefaultRequestMiddleware } from '../../../shared/core/middlewares/favicon.middleware' 29 | import { SecurityMiddleware } from '../../../shared/core/middlewares/security.middleware' 30 | import { GlobalModule } from '../../../shared/global/global.module' 31 | import { AppController } from './app.controller' 32 | import { AuthModule } from './auth/auth.module' 33 | import { MasterModule } from './master/master.module' 34 | import { SharedModule } from './shared/shared.module' 35 | const providers: Provider[] = [ 36 | { 37 | provide: APP_PIPE, 38 | useFactory: () => { 39 | return new ValidationPipe({ 40 | transform: true, 41 | whitelist: true, 42 | errorHttpStatusCode: 422, 43 | forbidUnknownValues: true, 44 | enableDebugMessages: isDev, 45 | stopAtFirstError: true, 46 | }) 47 | }, 48 | }, 49 | { 50 | provide: APP_INTERCEPTOR, 51 | useClass: JSONSerializeInterceptor, 52 | }, 53 | ] 54 | 55 | if (process.env.NODE_ENV === 'production') { 56 | providers.push({ 57 | provide: APP_GUARD, 58 | useClass: SpiderGuard, 59 | }) 60 | } 61 | 62 | @Module({ 63 | imports: [ 64 | CommonModule, 65 | DbModule, 66 | GatewayModule, 67 | AuthModule, 68 | MasterModule, 69 | SharedModule, 70 | GlobalModule, 71 | RedisModule, 72 | ], 73 | providers, 74 | controllers: [AppController], 75 | }) 76 | export class AppModule implements NestModule { 77 | configure(consumer: MiddlewareConsumer) { 78 | consumer 79 | .apply(AnalyzeMiddleware) 80 | .forRoutes({ path: '(.*?)', method: RequestMethod.GET }) 81 | .apply(SkipBrowserDefaultRequestMiddleware, SecurityMiddleware) 82 | .forRoutes({ path: '(.*?)', method: RequestMethod.ALL }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /apps/server/src/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-21 11:05:42 4 | * @LastEditTime: 2021-01-29 15:04:49 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/main.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { NestFactory } from '@nestjs/core' 11 | import { NestFastifyApplication } from '@nestjs/platform-fastify' 12 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' 13 | import { mkdirSync } from 'fs' 14 | import { AllExceptionsFilter } from 'shared/core/filters/any-exception.filter' 15 | import { ResponseInterceptor } from 'shared/core/interceptors/response.interceptors' 16 | import { AppModule } from './app.module' 17 | import { DATA_DIR, TEMP_DIR } from '../../../shared/constants' 18 | import { fastifyApp } from '../../../shared/core/adapt/fastify' 19 | import { ExtendsIoAdapter } from '../../../shared/core/gateway/extend.gateway' 20 | import { isDev } from '../../../shared/utils' 21 | import { Logger } from '@nestjs/common' 22 | 23 | const PORT = parseInt(process.env.PORT) || 2333 24 | const APIVersion = 1 25 | const Origin = process.env.ORIGIN || '' 26 | 27 | // ready for start server 28 | mkdirSync(DATA_DIR, { recursive: true }) 29 | mkdirSync(TEMP_DIR, { recursive: true }) 30 | 31 | // bootstrap server 32 | async function bootstrap() { 33 | const app = await NestFactory.create( 34 | AppModule, 35 | fastifyApp, 36 | ) 37 | app.useWebSocketAdapter(new ExtendsIoAdapter(app)) 38 | app.useGlobalFilters(new AllExceptionsFilter()) 39 | app.useGlobalInterceptors(new ResponseInterceptor()) 40 | 41 | const hosts = Origin.split(',').map((host) => new RegExp(host, 'i')) 42 | 43 | app.enableCors({ 44 | origin: (origin, callback) => { 45 | const allow = hosts.some((host) => host.test(origin)) 46 | 47 | callback(null, allow) 48 | }, 49 | credentials: true, 50 | }) 51 | 52 | app.setGlobalPrefix(isDev ? '' : `api/v${APIVersion}`) 53 | if (isDev) { 54 | const options = new DocumentBuilder() 55 | .setTitle('API') 56 | .setDescription('The blog API description') 57 | .setVersion(`${APIVersion}`) 58 | .addSecurity('bearer', { 59 | type: 'http', 60 | scheme: 'bearer', 61 | }) 62 | .addBearerAuth() 63 | .build() 64 | const document = SwaggerModule.createDocument(app, options) 65 | SwaggerModule.setup('api-docs', app, document) 66 | } 67 | 68 | await app.listen(PORT, '0.0.0.0', () => { 69 | if (isDev) { 70 | Logger.debug(`http://localhost:${PORT}/api-docs`) 71 | } 72 | Logger.log('Server is up.') 73 | }) 74 | 75 | if (module.hot) { 76 | module.hot.accept() 77 | module.hot.dispose(() => app.close()) 78 | } 79 | } 80 | bootstrap() 81 | declare const module: any 82 | -------------------------------------------------------------------------------- /apps/server/src/gateway/web/events.gateway.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-21 18:59:01 4 | * @LastEditTime: 2021-02-24 21:22:29 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/gateway/web/events.gateway.ts 7 | * @Copyright 8 | */ 9 | 10 | import { RedisNames } from '@libs/common/redis/redis.types' 11 | import { 12 | ConnectedSocket, 13 | GatewayMetadata, 14 | MessageBody, 15 | OnGatewayConnection, 16 | OnGatewayDisconnect, 17 | SubscribeMessage, 18 | WebSocketGateway, 19 | } from '@nestjs/websockets' 20 | import { plainToClass } from 'class-transformer' 21 | import { validate } from 'class-validator' 22 | import { RedisService } from 'nestjs-redis' 23 | import { BaseGateway } from '../base.gateway' 24 | import { EventTypes } from '../events.types' 25 | import { DanmakuDto } from './dtos/danmaku.dto' 26 | import dayjs = require('dayjs') 27 | @WebSocketGateway({ 28 | namespace: 'web', 29 | }) 30 | export class WebEventsGateway 31 | extends BaseGateway 32 | implements OnGatewayConnection, OnGatewayDisconnect 33 | { 34 | constructor(private readonly redisService: RedisService) { 35 | super() 36 | } 37 | 38 | // @SubscribeMessage(EventTypes.VISITOR_ONLINE) 39 | async sendOnlineNumber() { 40 | return { 41 | online: this.wsClients.length, 42 | timestamp: new Date().toISOString(), 43 | } 44 | } 45 | @SubscribeMessage(EventTypes.DANMAKU_CREATE) 46 | createNewDanmaku( 47 | @MessageBody() data: DanmakuDto, 48 | @ConnectedSocket() client: SocketIO.Socket, 49 | ) { 50 | const validator = plainToClass(DanmakuDto, data) 51 | validate(validator).then((errors) => { 52 | if (errors.length > 0) { 53 | return client.send(errors) 54 | } 55 | this.broadcast(EventTypes.DANMAKU_CREATE, data) 56 | client.send([]) 57 | }) 58 | } 59 | 60 | async handleConnection(client: SocketIO.Socket) { 61 | this.wsClients.push(client) 62 | this.broadcast(EventTypes.VISITOR_ONLINE, await this.sendOnlineNumber()) 63 | 64 | process.nextTick(async () => { 65 | const redisClient = this.redisService.getClient(RedisNames.MaxOnlineCount) 66 | const dateFormat = dayjs().format('YYYY-MM-DD') 67 | const count = +(await redisClient.get(dateFormat)) || 0 68 | await redisClient.set(dateFormat, Math.max(count, this.wsClients.length)) 69 | const key = dateFormat + '_total' 70 | const totalCount = +(await redisClient.get(key)) || 0 71 | await redisClient.set(key, totalCount + 1) 72 | }) 73 | 74 | super.handleConnect(client) 75 | } 76 | async handleDisconnect(client: SocketIO.Socket) { 77 | super.handleDisconnect(client) 78 | this.broadcast(EventTypes.VISITOR_OFFLINE, await this.sendOnlineNumber()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apps/server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-04-30 12:21:51 4 | * @LastEditTime: 2021-03-21 20:09:55 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/app.controller.ts 7 | * @Copyright 8 | */ 9 | 10 | import { RedisNames } from '@libs/common/redis/redis.types' 11 | import { Option } from '@libs/db/models/option.model' 12 | import { 13 | CacheTTL, 14 | CACHE_MANAGER, 15 | Controller, 16 | Get, 17 | HttpCode, 18 | Inject, 19 | Post, 20 | Req, 21 | UnprocessableEntityException, 22 | } from '@nestjs/common' 23 | import { ApiTags } from '@nestjs/swagger' 24 | import { ReturnModelType } from '@typegoose/typegoose' 25 | import { Cache } from 'cache-manager' 26 | import { Auth } from 'core/decorators/auth.decorator' 27 | import { FastifyReply } from 'fastify' 28 | import { RedisService } from 'nestjs-redis' 29 | import { InjectModel } from 'nestjs-typegoose' 30 | import { CACHE_KEY_PREFIX } from 'shared/constants' 31 | import { getIp } from '../../../shared/utils/ip' 32 | 33 | @Controller() 34 | @ApiTags('Root Routes') 35 | export class AppController { 36 | constructor( 37 | @InjectModel(Option) 38 | private readonly optionModel: ReturnModelType, 39 | private readonly redisService: RedisService, 40 | @Inject(CACHE_MANAGER) private cacheManager: Cache, 41 | ) {} 42 | 43 | @Get('ping') 44 | async sayHello() { 45 | return 'pong' 46 | } 47 | 48 | @Post('like_this') 49 | @CacheTTL(0.001) 50 | @HttpCode(204) 51 | async likeThis( 52 | @Req() 53 | req: FastifyReply, 54 | ) { 55 | const ip = getIp(req as any) 56 | const redis = this.redisService.getClient(RedisNames.Like) 57 | const isLikedBefore = await redis.sismember('site', ip) 58 | if (isLikedBefore) { 59 | throw new UnprocessableEntityException('一天一次就够啦') 60 | } else { 61 | redis.sadd('site', ip) 62 | } 63 | 64 | await this.optionModel.updateOne( 65 | { 66 | name: 'like', 67 | }, 68 | { 69 | $inc: { 70 | // @ts-ignore 71 | value: 1, 72 | }, 73 | }, 74 | { upsert: true }, 75 | ) 76 | 77 | return 78 | } 79 | 80 | @Get('like_this') 81 | @CacheTTL(0.001) 82 | async getLikeNumber() { 83 | const doc = await this.optionModel.findOne({ name: 'like' }).lean() 84 | return doc ? doc.value : 0 85 | } 86 | 87 | @Get('clean_catch') 88 | @HttpCode(204) 89 | @CacheTTL(0.001) 90 | @Auth() 91 | async cleanCatch() { 92 | const keys: string[] = await this.cacheManager.store.keys( 93 | CACHE_KEY_PREFIX + '*', 94 | ) 95 | 96 | for await (const key of keys) { 97 | await this.cacheManager.store.del(key) 98 | } 99 | return 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /apps/server/src/shared/options/options.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-08 20:01:58 4 | * @LastEditTime: 2021-01-15 14:08:36 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/shared/options/options.controller.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { 11 | Body, 12 | Controller, 13 | Get, 14 | Param, 15 | Patch, 16 | Query, 17 | UnprocessableEntityException, 18 | } from '@nestjs/common' 19 | import { ApiTags } from '@nestjs/swagger' 20 | import { IsNotEmpty, IsString } from 'class-validator' 21 | import { 22 | ConfigsService, 23 | IConfig, 24 | IConfigKeys, 25 | } from 'shared/global/configs/configs.service' 26 | import { OptionsService } from 'apps/server/src/shared/options/options.service' 27 | import { Auth } from '../../../../../shared/core/decorators/auth.decorator' 28 | import { AdminEventsGateway } from '../../gateway/admin/events.gateway' 29 | import { NotesService } from '../notes/notes.service' 30 | import { PageService } from '../page/page.service' 31 | import { PostsService } from '../posts/posts.service' 32 | import { isDev } from 'utils/index' 33 | class ConfigKeyDto { 34 | @IsString() 35 | @IsNotEmpty() 36 | key: keyof IConfig 37 | } 38 | 39 | @Controller('options') 40 | @ApiTags('Option Routes') 41 | @Auth() 42 | export class OptionsController { 43 | constructor( 44 | private readonly adminService: OptionsService, 45 | private readonly configs: ConfigsService, 46 | private readonly postService: PostsService, 47 | private readonly noteService: NotesService, 48 | private readonly pageService: PageService, 49 | private readonly adminEventGateway: AdminEventsGateway, 50 | ) {} 51 | 52 | @Get() 53 | getOption() { 54 | return this.configs.getConfig() 55 | } 56 | 57 | @Get(':key') 58 | getOptionKey(@Param('key') key: keyof IConfig) { 59 | if (typeof key !== 'string' && !key) { 60 | throw new UnprocessableEntityException( 61 | 'key must be IConfigKeys, got ' + key, 62 | ) 63 | } 64 | const value = this.configs.get(key) 65 | if (!value) { 66 | throw new UnprocessableEntityException('key is not exists.') 67 | } 68 | return { data: value } 69 | } 70 | 71 | @Patch(':key') 72 | patch(@Param() params: ConfigKeyDto, @Body() body: Record) { 73 | if (typeof body !== 'object') { 74 | throw new UnprocessableEntityException('body must be object') 75 | } 76 | return this.adminService.patchAndValid(params.key, body) 77 | } 78 | 79 | @Get('refresh_images') 80 | async refreshImagesAllMarkdown(@Query('socket_id') socketId: string) { 81 | const socket = this.adminEventGateway.findClientById(socketId) 82 | if (!socket && !isDev) { 83 | return 84 | } 85 | 86 | ;[this.postService, this.noteService, this.pageService].forEach( 87 | async (s) => { 88 | s.refreshImageSize(socket) 89 | }, 90 | ) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /shared/core/interceptors/secret.interceptors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2021-03-11 22:14:27 4 | * @LastEditTime: 2021-03-12 10:12:13 5 | * @LastEditors: Innei 6 | * @FilePath: /server/shared/core/interceptors/secret.interceptors.ts 7 | * Mark: Coding with Love 8 | */ 9 | import Note from '@libs/db/models/note.model' 10 | import { 11 | CallHandler, 12 | ExecutionContext, 13 | Injectable, 14 | NestInterceptor, 15 | } from '@nestjs/common' 16 | import * as locale from 'dayjs/locale/zh' 17 | import * as relativeTime from 'dayjs/plugin/relativeTime' 18 | import { IncomingMessage } from 'http' 19 | import { Observable } from 'rxjs' 20 | import { map } from 'rxjs/operators' 21 | import dayjs = require('dayjs') 22 | import { GqlExecutionContext } from '@nestjs/graphql' 23 | import { AnyType } from 'apps/graphql/src/shared/base/interfaces' 24 | dayjs.extend(relativeTime) 25 | dayjs.locale(locale) 26 | export interface Response { 27 | data: T 28 | } 29 | 30 | @Injectable() 31 | export class NoteSecretInterceptor 32 | implements NestInterceptor> { 33 | intercept( 34 | context: ExecutionContext, 35 | next: CallHandler, 36 | ): Observable> { 37 | const http = context.switchToHttp() 38 | const req = http.getRequest() as IncomingMessage 39 | // @ts-ignore 40 | const isMaster = req.isMaster 41 | if (isMaster) { 42 | return next.handle() 43 | } 44 | 45 | return next.handle().pipe(map(handle())) 46 | } 47 | } 48 | 49 | function filterSecretNote(note: Note, now: Date) { 50 | const data = note 51 | const secret = data.secret 52 | if (secret && new Date(secret).getTime() - now.getTime() > 0) { 53 | data.text = '这篇文章需要在 ' + dayjs(secret).fromNow() + '才能查看哦' 54 | data.mood = undefined 55 | data.weather = undefined 56 | delete data.mood 57 | delete data.weather 58 | } 59 | } 60 | 61 | @Injectable() 62 | export class NoteSecretGQLInterceptor 63 | implements NestInterceptor { 64 | intercept(context: ExecutionContext, next: CallHandler): Observable { 65 | // const http = context.switchToHttp() 66 | const req = GqlExecutionContext.create(context).getContext() 67 | .req as IncomingMessage 68 | // @ts-ignore 69 | const isMaster = req.isMaster 70 | if (isMaster) { 71 | return next.handle() 72 | } 73 | 74 | return next.handle().pipe(map(handle())) 75 | } 76 | } 77 | function handle(): (value: any, index: number) => any { 78 | const now = new Date() 79 | return (data) => { 80 | if (!data) return data 81 | // console.log(data) 82 | const dataField = data.data as Note[] | Note 83 | if (!dataField) { 84 | filterSecretNote(data, now) 85 | } else { 86 | if (Array.isArray(dataField)) { 87 | dataField.forEach((data) => filterSecretNote(data, now)) 88 | } else filterSecretNote(dataField, now) 89 | } 90 | 91 | return data 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/aggregate/aggregate.service.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-10-03 10:14:56 4 | * @LastEditTime: 2020-10-03 10:59:37 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server-next/src/shared/aggregate/aggregate.service.ts 7 | * @Mark: Coding with Love 8 | */ 9 | import Category from '@libs/db/models/category.model' 10 | import Comment from '@libs/db/models/comment.model' 11 | import Note from '@libs/db/models/note.model' 12 | import Page from '@libs/db/models/page.model' 13 | import Post from '@libs/db/models/post.model' 14 | import { Project } from '@libs/db/models/project.model' 15 | import { Say } from '@libs/db/models/say.model' 16 | import { Injectable } from '@nestjs/common' 17 | import { ReturnModelType } from '@typegoose/typegoose' 18 | import { AnyParamConstructor } from '@typegoose/typegoose/lib/types' 19 | import { ConfigsService, ToolsService } from 'shared/global' 20 | import { pick } from 'lodash' 21 | import { InjectModel } from 'nestjs-typegoose' 22 | 23 | @Injectable() 24 | export class AggregateService { 25 | constructor( 26 | @InjectModel(Post) public readonly postModel: ReturnModelType, 27 | @InjectModel(Note) public readonly noteModel: ReturnModelType, 28 | @InjectModel(Comment) 29 | public readonly commentModel: ReturnModelType, 30 | @InjectModel(Say) public readonly sayModel: ReturnModelType, 31 | @InjectModel(Project) 32 | public readonly projectModel: ReturnModelType, 33 | @InjectModel(Category) 34 | public readonly categoryModel: ReturnModelType, 35 | @InjectModel(Page) public readonly pageModel: ReturnModelType, 36 | 37 | private readonly configs: ConfigsService, 38 | public readonly tools: ToolsService, 39 | ) {} 40 | 41 | private findTop< 42 | U extends AnyParamConstructor, 43 | T extends ReturnModelType 44 | >(model: T, condition = {}, size = 6) { 45 | return model.find(condition).sort({ created: -1 }).limit(size) 46 | } 47 | 48 | async topActivity(size = 6, isMaster = false) { 49 | const notes = await this.findTop( 50 | this.noteModel, 51 | !isMaster 52 | ? { 53 | hide: false, 54 | password: undefined, 55 | } 56 | : {}, 57 | size, 58 | ).lean() 59 | 60 | const _posts = (await this.findTop( 61 | this.postModel, 62 | !isMaster ? { hide: false } : {}, 63 | size, 64 | ) 65 | .populate('categoryId') 66 | .lean()) as any[] 67 | 68 | const posts = _posts.map((post) => { 69 | post.category = pick(post.categoryId, ['name', 'slug']) 70 | return post 71 | }) 72 | 73 | const projects = await this.projectModel 74 | .find() 75 | .sort({ create: -1 }) 76 | .limit(size) 77 | 78 | const says = await this.sayModel.find({}).sort({ create: -1 }).limit(size) 79 | 80 | return { notes, posts, projects, says } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /apps/graphql/src/shared/categories/category.input.ts: -------------------------------------------------------------------------------- 1 | import { UnprocessableEntityException } from '@nestjs/common' 2 | import { ArgsType, Field, ObjectType, registerEnumType } from '@nestjs/graphql' 3 | import { IsBooleanOrString } from 'utils/validator-decorators/isBooleanOrString' 4 | /* 5 | * @Author: Innei 6 | * @Date: 2020-04-30 12:21:51 7 | * @LastEditTime: 2021-01-15 14:27:12 8 | * @LastEditors: Innei 9 | * @FilePath: /server/apps/graphql/src/shared/categories/category.input.ts 10 | * @MIT 11 | */ 12 | import { Transform } from 'class-transformer' 13 | import { 14 | IsBoolean, 15 | IsEnum, 16 | IsMongoId, 17 | IsNotEmpty, 18 | IsOptional, 19 | IsString, 20 | } from 'class-validator' 21 | import { uniq } from 'lodash' 22 | import { CategoryItemModel } from '../../graphql/models/category.model' 23 | 24 | export enum CategoryType { 25 | Category, 26 | Tag, 27 | } 28 | 29 | @ArgsType() 30 | export class CategoryDto { 31 | @IsString() 32 | @IsNotEmpty() 33 | name: string 34 | 35 | @IsEnum(CategoryType) 36 | @IsOptional() 37 | @Field(() => CategoryType, { nullable: true }) 38 | type?: CategoryType 39 | 40 | @IsString() 41 | @IsNotEmpty() 42 | @IsOptional() 43 | slug?: string 44 | } 45 | @ArgsType() 46 | export class SlugArgsDto { 47 | @IsString() 48 | @IsNotEmpty() 49 | @IsOptional() 50 | slug?: string 51 | } 52 | 53 | @ArgsType() 54 | export class MultiQueryTagAndCategoryDto { 55 | @IsOptional() 56 | @Transform(({ value: val }) => { 57 | if (val === '1' || val === 'true') { 58 | return true 59 | } else { 60 | return val 61 | } 62 | }) 63 | @IsBooleanOrString() 64 | @Field(() => [Boolean, String]) 65 | tag?: boolean | string 66 | } 67 | @ArgsType() 68 | export class MultiCategoriesArgsDto { 69 | @IsOptional() 70 | @IsMongoId({ 71 | each: true, 72 | message: '多分类查询使用逗号分隔, 应为 mongoID', 73 | }) 74 | @Transform(({ value: v }) => uniq(v.split(','))) 75 | @Field(() => [String], { nullable: true }) 76 | ids?: Array 77 | 78 | @IsOptional() 79 | @IsBoolean() 80 | @Transform((b) => Boolean(b)) 81 | joint?: boolean 82 | 83 | @IsOptional() 84 | @Transform(({ value: val }: { value: string }) => { 85 | if (typeof val !== 'string') { 86 | throw new UnprocessableEntityException('type must be a string') 87 | } 88 | switch (val.toLowerCase()) { 89 | case 'category': 90 | return CategoryType.Category 91 | case 'tag': 92 | return CategoryType.Tag 93 | default: 94 | return CategoryType.Category 95 | } 96 | }) 97 | @Field(() => CategoryType) 98 | type?: CategoryType 99 | } 100 | 101 | @ObjectType() 102 | export class CategoryPagerModel { 103 | @Field(() => [CategoryItemModel], { nullable: true }) 104 | data: CategoryItemModel[] 105 | 106 | // pager: PagerModel 107 | } 108 | 109 | registerEnumType(CategoryType, { name: 'CategoryType' }) 110 | -------------------------------------------------------------------------------- /apps/server/src/shared/uploads/uploads.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-07-31 19:38:38 4 | * @LastEditTime: 2020-07-31 19:59:08 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/shared/uploads/uploads.controller.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { 11 | FileLocate, 12 | FileType, 13 | getEnumFromType, 14 | } from '@libs/db/models/file.model' 15 | import { 16 | Controller, 17 | Delete, 18 | Get, 19 | Param, 20 | Post, 21 | Query, 22 | Req, 23 | Res, 24 | } from '@nestjs/common' 25 | import { ApiTags } from '@nestjs/swagger' 26 | import { FastifyReply, FastifyRequest } from 'fastify' 27 | import { Auth } from 'shared/core/decorators/auth.decorator' 28 | import { ApplyUpload } from 'shared/core/decorators/file.decorator' 29 | import { CannotFindException } from 'shared/core/exceptions/cant-find.exception' 30 | import { UploadsService } from 'apps/server/src/shared/uploads/uploads.service' 31 | import { MongoIdDto } from '../base/dto/id.dto' 32 | import { FileTypeQueryDto } from './dto/filetype.dto' 33 | 34 | @Controller('uploads') 35 | @ApiTags('File Routes') 36 | export class UploadsController { 37 | constructor(private readonly service: UploadsService) {} 38 | 39 | @Post('image') 40 | @ApplyUpload({ description: 'Upload images' }) 41 | @Auth() 42 | async uploadImage( 43 | @Req() req: FastifyRequest, 44 | @Query() query: FileTypeQueryDto, 45 | ) { 46 | const { type = FileType.IMAGE } = query 47 | const file = await this.service.validImage(req) 48 | const data = await this.service.saveImage( 49 | { data: await file.toBuffer(), filename: file.filename }, 50 | type, 51 | ) 52 | return { ...data } 53 | } 54 | 55 | @Get(':type/:hashname') 56 | async getImage( 57 | @Param('hashname') name: string, 58 | @Param('type') _type: string, 59 | @Res() res: FastifyReply, 60 | ) { 61 | const type = getEnumFromType(_type.toUpperCase() as keyof typeof FileType) 62 | if (!(type in FileType)) { 63 | throw new CannotFindException() 64 | } 65 | 66 | const { buffer, mime, url, locate } = await this.service.checkFileExist( 67 | name, 68 | type, 69 | ) 70 | if (locate === FileLocate.Online && url) { 71 | return res.redirect(302, url) 72 | } 73 | const stream = this.service.getReadableStream(buffer) 74 | res.type(mime).send(stream) 75 | } 76 | 77 | @Get('image/info/:hashname') 78 | async getImageInfo( 79 | @Param('hashname') name: string, 80 | @Query() query: FileTypeQueryDto, 81 | ) { 82 | const { type = FileType.IMAGE } = query 83 | return await this.service.getImageInfo(name, type) 84 | } 85 | @Delete(':id') 86 | @Auth() 87 | async deleteFile(@Param() param: MongoIdDto) { 88 | const { id } = param 89 | return this.service.deleteFile(id) 90 | } 91 | @Auth() 92 | @Get() 93 | async getFilesList(@Query() query: FileTypeQueryDto) { 94 | return await this.service.findFiles(query.type) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /apps/server/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-26 11:10:24 4 | * @LastEditTime: 2020-05-30 14:12:53 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/auth/auth.controller.ts 7 | * @Copyright 8 | */ 9 | 10 | import { 11 | Body, 12 | Controller, 13 | Delete, 14 | Get, 15 | HttpService, 16 | Post, 17 | Query, 18 | Scope, 19 | UseGuards, 20 | } from '@nestjs/common' 21 | import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' 22 | import { Transform } from 'class-transformer' 23 | import { 24 | IsDate, 25 | isMongoId, 26 | IsNotEmpty, 27 | IsOptional, 28 | IsString, 29 | } from 'class-validator' 30 | import { Auth } from '../../../../shared/core/decorators/auth.decorator' 31 | import { Master } from '../../../../shared/core/decorators/guest.decorator' 32 | import { AdminEventsGateway } from '../gateway/admin/events.gateway' 33 | import { MongoIdDto } from '../shared/base/dto/id.dto' 34 | import { AuthService } from './auth.service' 35 | import { RolesGuard } from './roles.guard' 36 | 37 | export class TokenDto { 38 | @IsDate() 39 | @IsOptional() 40 | @Transform(({ value: v }) => new Date(v)) 41 | expired?: Date 42 | 43 | @IsString() 44 | @IsNotEmpty() 45 | name: string 46 | } 47 | 48 | @Controller({ 49 | path: 'auth', 50 | scope: Scope.REQUEST, 51 | }) 52 | @ApiTags('Auth Routes') 53 | export class AuthController { 54 | constructor( 55 | private readonly authService: AuthService, 56 | private readonly adminGateway: AdminEventsGateway, 57 | private readonly http: HttpService, 58 | ) {} 59 | 60 | @Get() 61 | @ApiOperation({ summary: '判断当前 Token 是否有效 ' }) 62 | @ApiBearerAuth() 63 | @UseGuards(RolesGuard) 64 | checkLogged(@Master() isMaster: boolean) { 65 | return { ok: ~~isMaster, isGuest: !isMaster } 66 | } 67 | 68 | @Get('token') 69 | @Auth() 70 | async getOrVerifyToken( 71 | @Query('token') token?: string, 72 | @Query('id') id?: string, 73 | ) { 74 | if (typeof token === 'string') { 75 | return await this.authService.verifyCustomToken(token) 76 | } 77 | if (id && typeof id === 'string' && isMongoId(id)) { 78 | return await this.authService.getTokenSecret(id) 79 | } 80 | return await this.authService.getAllAccessToken() 81 | } 82 | 83 | @Post('token') 84 | @Auth() 85 | async generateToken(@Body() body: TokenDto) { 86 | const { expired, name } = body 87 | const token = await this.authService.generateAccessToken() 88 | const model = { 89 | expired, 90 | token, 91 | name, 92 | } 93 | await this.authService.saveToken(model) 94 | return model 95 | } 96 | @Delete('token') 97 | @Auth() 98 | async deleteToken(@Query() query: MongoIdDto) { 99 | const { id } = query 100 | await this.authService.deleteToken(id) 101 | this.adminGateway.handleTokenExpired(id) 102 | return 'OK' 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /shared/core/interceptors/response.interceptors.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-11-24 16:20:37 4 | * @LastEditTime: 2021-03-21 20:12:56 5 | * @LastEditors: Innei 6 | * @FilePath: /server/shared/core/interceptors/response.interceptors.ts 7 | * Mark: Coding with Love 8 | */ 9 | import { 10 | CallHandler, 11 | ExecutionContext, 12 | Injectable, 13 | NestInterceptor, 14 | UnprocessableEntityException, 15 | } from '@nestjs/common' 16 | import { isArrayLike, isObjectLike } from 'lodash' 17 | import { Observable } from 'rxjs' 18 | import { map } from 'rxjs/operators' 19 | import { isDev } from 'utils/index' 20 | 21 | const snakecaseKeys = require('snakecase-keys') 22 | export interface Response { 23 | data: T 24 | } 25 | 26 | @Injectable() 27 | export class ResponseInterceptor implements NestInterceptor> { 28 | intercept( 29 | context: ExecutionContext, 30 | next: CallHandler, 31 | ): Observable> { 32 | const reorganize = (data) => { 33 | if (!data) { 34 | throw new UnprocessableEntityException('数据丢失了(。 ́︿ ̀。)') 35 | } 36 | return typeof data !== 'object' || data.__proto__.constructor === Object 37 | ? { ...data } 38 | : { data } 39 | } 40 | return next.handle().pipe( 41 | map((data) => 42 | typeof data === 'undefined' 43 | ? // HINT: hack way to solve `undefined` as cache value set into redis got an error. 44 | '' 45 | : typeof data === 'object' && data !== null 46 | ? { ...reorganize(data) } 47 | : data, 48 | ), 49 | ) 50 | } 51 | } 52 | 53 | export class JSONSerializeInterceptor implements NestInterceptor { 54 | intercept(context: ExecutionContext, next: CallHandler): Observable { 55 | return next.handle().pipe( 56 | map((data) => { 57 | return this.serialize(data) 58 | }), 59 | ) 60 | } 61 | 62 | private serialize(obj: any) { 63 | if (!isObjectLike(obj)) { 64 | return obj 65 | } 66 | 67 | if (isArrayLike(obj)) { 68 | obj = Array.from(obj).map((i) => { 69 | return this.serialize(i) 70 | }) 71 | } else { 72 | // if is Object 73 | 74 | if (obj.toJSON || obj.toObject) { 75 | obj = obj.toJSON?.() ?? obj.toObject?.() 76 | } 77 | 78 | const keys = Object.keys(obj) 79 | for (const key of keys) { 80 | const val = obj[key] 81 | // first 82 | if (!isObjectLike(val)) { 83 | continue 84 | } 85 | 86 | if (val.toJSON) { 87 | obj[key] = val.toJSON() 88 | // second 89 | if (!isObjectLike(obj[key])) { 90 | continue 91 | } 92 | } 93 | obj[key] = this.serialize(obj[key]) 94 | // obj[key] = snakecaseKeys(obj[key]) 95 | } 96 | obj = snakecaseKeys(obj) 97 | delete obj.v 98 | } 99 | return obj 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /apps/server/src/shared/notes/dto/note.dto.ts: -------------------------------------------------------------------------------- 1 | import Note, { Coordinate } from '@libs/db/models/note.model' 2 | import { ApiProperty } from '@nestjs/swagger' 3 | import { Transform, Type } from 'class-transformer' 4 | import { 5 | IsBoolean, 6 | IsDate, 7 | IsDefined, 8 | IsEnum, 9 | IsInt, 10 | IsNotEmpty, 11 | IsNotEmptyObject, 12 | IsNumber, 13 | IsOptional, 14 | IsString, 15 | Max, 16 | Min, 17 | ValidateIf, 18 | ValidateNested, 19 | } from 'class-validator' 20 | import { IsNilOrString } from 'utils/validator-decorators/isNilOrString' 21 | import { PagerDto } from '../../base/dto/pager.dto' 22 | 23 | export class NoteDto implements Partial> { 24 | @IsString() 25 | @Transform(({ value: title }) => (title.length === 0 ? '无题' : title)) 26 | @IsOptional() 27 | title: string 28 | 29 | @IsString() 30 | @IsOptional() 31 | @IsNotEmpty() 32 | text: string 33 | 34 | @IsOptional() 35 | @IsString() 36 | mood?: string 37 | 38 | @IsString() 39 | @IsOptional() 40 | weather?: string 41 | 42 | @IsOptional() 43 | @IsBoolean() 44 | hasMemory?: boolean = false 45 | 46 | @IsBoolean() 47 | @IsOptional() 48 | hide?: boolean = false 49 | 50 | @IsNilOrString() 51 | @IsOptional() 52 | @IsNotEmpty() 53 | @Transform(({ value: val }) => (String(val).length === 0 ? null : val)) 54 | password?: string 55 | 56 | @IsOptional() 57 | @IsDate() 58 | @Transform(({ value }) => (value ? new Date(value) : null)) 59 | secret?: Date 60 | 61 | @IsOptional() 62 | @IsNotEmptyObject() 63 | @Transform(({ value }) => undefined) 64 | options?: Record 65 | 66 | @ValidateNested({ each: true }) 67 | @IsOptional() 68 | @Type(() => NoteMusicDto) 69 | music?: NoteMusicDto[] 70 | 71 | @IsOptional() 72 | @IsString() 73 | location?: string 74 | 75 | @ValidateNested() 76 | @Type(() => Coordinate) 77 | @IsOptional() 78 | coordinates?: Coordinate 79 | } 80 | 81 | export class NoteMusicDto { 82 | @IsString() 83 | @IsNotEmpty() 84 | type: string 85 | 86 | @IsString() 87 | @IsNotEmpty() 88 | id: string 89 | } 90 | export class ListQueryDto { 91 | @IsNumber() 92 | @Max(20) 93 | @Min(1) 94 | @Transform(({ value: v }) => parseInt(v)) 95 | @IsOptional() 96 | @ApiProperty() 97 | size: number 98 | } 99 | 100 | export class NidType { 101 | @IsInt() 102 | @Min(1) 103 | @IsDefined() 104 | @ApiProperty() 105 | @Transform(({ value: val }) => parseInt(val)) 106 | nid: number 107 | } 108 | 109 | export class PasswordQueryDto { 110 | @IsString() 111 | @IsOptional() 112 | @IsNotEmpty() 113 | password?: string 114 | } 115 | export class NoteQueryDto extends PagerDto { 116 | @IsOptional() 117 | @IsEnum(['title', 'created', 'modified', 'weather', 'mood']) 118 | sortBy?: string 119 | 120 | @IsOptional() 121 | @IsEnum([1, -1]) 122 | @ValidateIf((o) => o.sortBy) 123 | @Transform(({ value: v }) => v | 0) 124 | sortOrder?: 1 | -1 125 | } 126 | -------------------------------------------------------------------------------- /apps/server/src/shared/options/options.service.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-08 20:01:58 4 | * @LastEditTime: 2021-01-15 14:12:43 5 | * @LastEditors: Innei 6 | * @FilePath: /server/apps/server/src/shared/options/options.service.ts 7 | * @Coding with Love 8 | */ 9 | 10 | import { 11 | Injectable, 12 | UnprocessableEntityException, 13 | ValidationPipe, 14 | } from '@nestjs/common' 15 | import { ClassConstructor, plainToClass } from 'class-transformer' 16 | import { validateSync, ValidatorOptions } from 'class-validator' 17 | import { 18 | BackupOptions, 19 | BaiduSearchOptions, 20 | CommentOptions, 21 | ImageBedDto, 22 | MailOptionsDto, 23 | SEODto, 24 | UrlDto, 25 | } from '../../../../../shared/global/configs/configs.dto' 26 | import { 27 | ConfigsService, 28 | IConfig, 29 | } from '../../../../../shared/global/configs/configs.service' 30 | 31 | @Injectable() 32 | export class OptionsService { 33 | constructor( 34 | // @InjectModel(Option) 35 | // private readonly optionModel: ReturnModelType, 36 | // @InjectModel(User) private readonly userModel: ReturnModelType, 37 | // @InjectModel(Post) private readonly postModel: ReturnModelType, 38 | // @InjectModel(Note) private readonly nodeModel: ReturnModelType, 39 | private readonly configs: ConfigsService, 40 | ) {} 41 | 42 | validOptions: ValidatorOptions = { 43 | whitelist: true, 44 | forbidNonWhitelisted: true, 45 | } 46 | validate = new ValidationPipe(this.validOptions) 47 | patchAndValid(key: keyof IConfig, value: any) { 48 | switch (key) { 49 | case 'url': { 50 | this.validWithDto(UrlDto, value) 51 | return this.configs.patch('url', value) 52 | } 53 | case 'commentOptions': { 54 | this.validWithDto(CommentOptions, value) 55 | return this.configs.patch('commentOptions', value) 56 | } 57 | case 'imageBed': { 58 | this.validWithDto(ImageBedDto, value) 59 | return this.configs.patch('imageBed', value) 60 | } 61 | case 'mailOptions': { 62 | this.validWithDto(MailOptionsDto, value) 63 | return this.configs.patch('mailOptions', value) 64 | } 65 | case 'seo': { 66 | this.validWithDto(SEODto, value) 67 | return this.configs.patch('seo', value) 68 | } 69 | case 'backupOptions': { 70 | this.validWithDto(BackupOptions, value) 71 | return this.configs.patch('backupOptions', value) 72 | } 73 | case 'baiduSearchOptions': { 74 | this.validWithDto(BaiduSearchOptions, value) 75 | 76 | return this.configs.patch('baiduSearchOptions', value) 77 | } 78 | default: { 79 | throw new UnprocessableEntityException('设置不存在') 80 | } 81 | } 82 | } 83 | 84 | private validWithDto(dto: ClassConstructor, value: any) { 85 | const validModel = plainToClass(dto, value) 86 | const errors = validateSync(validModel, this.validOptions) 87 | if (errors.length > 0) { 88 | const error = this.validate.createExceptionFactory()(errors as any[]) 89 | throw error 90 | } 91 | return true 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /apps/server/src/plugins/parseMarkdownYAML.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | export class ParseMarkdownYAML { 5 | constructor(private basePath: string) {} 6 | 7 | async getFiles(): Promise { 8 | const dir = await fs.promises.opendir(this.basePath) 9 | const files: string[] = [] 10 | for await (const dirent of dir) { 11 | files.push(dirent.name) 12 | } 13 | // await dir.close() 14 | return files 15 | } 16 | 17 | async readFileAndParse(file: string) { 18 | const pwd = path.resolve(this.basePath, file) 19 | const raw = fs.readFileSync(pwd, { encoding: 'utf8' }) 20 | // const parttenYAML = raw.matchAll(/---(.*?)---(.*)$/gs) 21 | const parts = /-{3,}\n(.*?)-{3,}\n*(.*)$/gms.exec(raw) 22 | if (!parts) { 23 | return { text: raw } 24 | } 25 | const parttenYAML = parts[1] 26 | const text = parts.pop() 27 | const parseYAML = parttenYAML.split('\n') 28 | 29 | const tags = [] as string[] 30 | const categories = [] as string[] 31 | 32 | let cur: 'cate' | 'tag' | null = null 33 | const meta: any = parseYAML.reduce((meta, current) => { 34 | const splitPart = current 35 | .trim() 36 | .split(':') 37 | .filter((item) => item.length) 38 | const sp = 39 | splitPart.length >= 2 40 | ? [ 41 | splitPart[0], 42 | splitPart 43 | .slice(1) 44 | .filter((item) => item.length) 45 | .join(':') 46 | .trim(), 47 | ] 48 | : [splitPart[0]] 49 | 50 | // console.log(sp) 51 | if (sp.length === 2) { 52 | const [property, value] = sp 53 | if (['date', 'updated'].includes(property)) { 54 | meta[property] = new Date(value.trim()) 55 | } else if (['categories:', 'tags:'].includes(property)) { 56 | cur = property === 'categories:' ? 'cate' : 'tag' 57 | } else meta[property] = value.trim() 58 | } else { 59 | const item = current.trim().replace(/^\s*-\s*/, '') 60 | 61 | if (['', 'tags:', 'categories:'].includes(item)) { 62 | cur = item === 'categories:' ? 'cate' : 'tag' 63 | return meta 64 | } 65 | if (cur === 'tag') { 66 | tags.push(item) 67 | } else { 68 | categories.push(item) 69 | } 70 | } 71 | return meta 72 | }, {}) 73 | 74 | meta.categories = categories 75 | meta.tags = tags 76 | meta.slug = file.split('.').slice(0, -1).join('.') 77 | return { meta, text } as ParsedModel 78 | } 79 | 80 | async start() { 81 | const files = await this.getFiles() 82 | const contents = [] as ParsedModel[] 83 | for await (const file of files) { 84 | contents.push(await this.readFileAndParse(file)) 85 | } 86 | return contents 87 | } 88 | } 89 | 90 | interface ParsedModel { 91 | meta?: { 92 | title: string 93 | updated: Date 94 | date: Date 95 | categories: Array 96 | tags: Array 97 | slug: string 98 | } 99 | text: string 100 | } 101 | -------------------------------------------------------------------------------- /apps/server/src/gateway/admin/events.gateway.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: Innei 3 | * @Date: 2020-05-21 11:05:42 4 | * @LastEditTime: 2020-06-07 13:38:48 5 | * @LastEditors: Innei 6 | * @FilePath: /mx-server/src/gateway/admin/events.gateway.ts 7 | * @MIT 8 | */ 9 | 10 | import { JwtService } from '@nestjs/jwt' 11 | import { 12 | GatewayMetadata, 13 | OnGatewayConnection, 14 | OnGatewayDisconnect, 15 | WebSocketGateway, 16 | WsException, 17 | } from '@nestjs/websockets' 18 | import { AuthService } from '../../auth/auth.service' 19 | import { BaseGateway } from '../base.gateway' 20 | import { EventTypes, NotificationTypes } from '../events.types' 21 | 22 | @WebSocketGateway({ namespace: 'admin' }) 23 | export class AdminEventsGateway 24 | extends BaseGateway 25 | implements OnGatewayConnection, OnGatewayDisconnect { 26 | constructor( 27 | private readonly jwtService: JwtService, 28 | private readonly authService: AuthService, 29 | ) { 30 | super() 31 | } 32 | 33 | async authFailed(client: SocketIO.Socket) { 34 | client.send(this.messageFormat(EventTypes.AUTH_FAILED, '认证失败')) 35 | client.disconnect() 36 | } 37 | async authToken(token: string): Promise { 38 | if (typeof token !== 'string') { 39 | return false 40 | } 41 | // first check this token is custom token in user 42 | const verifyCustomToken = await this.authService.verifyCustomToken(token) 43 | if (verifyCustomToken) { 44 | return true 45 | } else { 46 | // if not, then verify jwt token 47 | try { 48 | const payload = this.jwtService.verify(token) 49 | const user = await this.authService.verifyPayload(payload) 50 | if (!user) { 51 | return false 52 | } 53 | } catch { 54 | return false 55 | } 56 | // is not crash, is verify 57 | return true 58 | } 59 | } 60 | async handleConnection(client: SocketIO.Socket) { 61 | const token = 62 | client.handshake.query.token || client.handshake.headers['authorization'] 63 | 64 | if (!(await this.authToken(token))) { 65 | return this.authFailed(client) 66 | } 67 | 68 | this.wsClients.push(client) 69 | super.handleConnect(client) 70 | } 71 | handleDisconnect(client: SocketIO.Socket) { 72 | super.handleDisconnect(client) 73 | } 74 | 75 | handleTokenExpired(token: string) { 76 | this.wsClients.some((client) => { 77 | const _token = 78 | client.handshake.query.token || 79 | client.handshake.headers['authorization'] 80 | if (token === _token) { 81 | client.disconnect() 82 | super.handleDisconnect(client) 83 | return true 84 | } 85 | return false 86 | }) 87 | } 88 | 89 | sendNotification({ 90 | payload, 91 | id, 92 | type, 93 | }: { 94 | payload?: { 95 | type: NotificationTypes 96 | message: string 97 | } 98 | id: string 99 | type?: EventTypes 100 | }) { 101 | const socket = super.findClientById(id) 102 | if (!socket) { 103 | throw new WsException('Socket 未找到, 无法发送消息') 104 | } 105 | socket.send( 106 | super.messageFormat(type ?? EventTypes.ADMIN_NOTIFICATION, payload), 107 | ) 108 | } 109 | } 110 | --------------------------------------------------------------------------------