Compare commits
318 Commits
88a017d6da
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 063181da5a | |||
| 0eed6cfdbf | |||
| e6087f43d0 | |||
| da98961b8b | |||
| 6d3efa57ca | |||
| d1d1870100 | |||
| 0758b9f75a | |||
| 6e73220962 | |||
| 8f2df85208 | |||
| 1bd717f84f | |||
| bb9fa3bcaa | |||
| bf3b2f7a94 | |||
| 325c2e3a87 | |||
| c262778373 | |||
| c782145e7e | |||
| cb2258aa64 | |||
| da16bc1dbe | |||
| 43e814fbe7 | |||
| 4c9505d476 | |||
| b79c84a004 | |||
| 70b90c3ed7 | |||
| 83b15ddf37 | |||
| 0f4d6be36f | |||
| a1a16afb76 | |||
| 70c89b783a | |||
| bd862e54fa | |||
| af0e9c6522 | |||
| acaf14c403 | |||
| f62e2ad2a6 | |||
| ec090e3e20 | |||
| 4e306adc9f | |||
| 38fa850e38 | |||
| 041e27c87d | |||
| 6d09087289 | |||
| 7e05789fe5 | |||
| e418476b20 | |||
| 90a67b681e | |||
| e777afc433 | |||
| 749cb2e13b | |||
| 7b7f940a60 | |||
| 0461c0ce70 | |||
| 613f9e1a8c | |||
| 2c75d6de4a | |||
| 65db82ce60 | |||
| 19dc49b10d | |||
| 3211e25bd6 | |||
| 359ab3b072 | |||
| b16e454058 | |||
| 2698d5f133 | |||
| e0b80ea422 | |||
| 448bed89bd | |||
| accd5b8754 | |||
| 73e409ce84 | |||
| 3821ef6657 | |||
| c872b55083 | |||
| c2914d8e29 | |||
| 0ebda96d62 | |||
| 1baff0712a | |||
| 4d9245aabb | |||
| 263ad2a0ae | |||
| 4bace58823 | |||
| d5d799b425 | |||
| b0a8ac9d66 | |||
| ef7dae1bae | |||
| e25c4f0455 | |||
| f1f32f03ec | |||
| cbd4ca5686 | |||
| 8992963da8 | |||
| 6f172c6cb6 | |||
| 02a73a5e33 | |||
| 44a1da5be2 | |||
| b4ade3bdd1 | |||
| 5d9dfe7382 | |||
| 393377b5da | |||
| 42e9bd0f0b | |||
| 7730ae7981 | |||
| 3f0e281d42 | |||
| 327f49bff0 | |||
| e1606b707e | |||
| 39f3e6b93c | |||
| 4f5f771e2b | |||
| e782b926a4 | |||
| dc0a8a1071 | |||
| 7ab982e4fd | |||
| 7a612f8480 | |||
| c6cc3a7098 | |||
| 088e168460 | |||
| b6d9a518e8 | |||
| 96e8d0e090 | |||
| 5e7ed3d6f7 | |||
| 134335021d | |||
| b764d3c932 | |||
| 367ec1d9a0 | |||
| af3b4b0cc3 | |||
| fcec99f1c5 | |||
| 7d065d1264 | |||
| 51ed3a7747 | |||
| 92a3510547 | |||
| ee4feb57c5 | |||
| 5159fa3606 | |||
| 53665f8847 | |||
| 30224774e6 | |||
| e3ca7ac027 | |||
| bb054f7f5a | |||
| 2a1e0d45dd | |||
| 8e0d7dc873 | |||
| 259fae4c63 | |||
| 9133b45744 | |||
| eb4301ba98 | |||
| 7f441f5126 | |||
| 054d505117 | |||
| 26142f9a71 | |||
| 11d503ab49 | |||
| 748ea70c0d | |||
| f1876e19bf | |||
| 17bcb8787a | |||
| c17108e094 | |||
| 5c29938730 | |||
| f16cbe5443 | |||
| f49a1503f0 | |||
| 4f782e4cea | |||
| 2dd088fdf3 | |||
| e5c0c354e5 | |||
| 21cee7c3f1 | |||
| 369fe28c5c | |||
| 17c065ae79 | |||
| 7bbcf2fac7 | |||
| d281a6c804 | |||
| 2f86362f4b | |||
| 448a7b48ba | |||
| a2972de417 | |||
| 98745895eb | |||
| 87d6c738c7 | |||
| 2f9b922485 | |||
| d2287bc363 | |||
| 8e33f1b61b | |||
| c2e5ab51df | |||
| b6f1495981 | |||
| 4df7de91d0 | |||
| c5463e9ffe | |||
| 96a76568cc | |||
| 9206b7fcc0 | |||
| 464931cc98 | |||
| 9e09a7bc72 | |||
| 3b2f4f1f40 | |||
| a661827842 | |||
| 9570fb4524 | |||
| 8039a3571d | |||
| c6471cc169 | |||
| 8645380fbf | |||
| 37988b4582 | |||
| f5f80385ad | |||
| 97352e26b2 | |||
| 6a44e902fd | |||
| 1246613fb1 | |||
| d2744689b2 | |||
| b75c4fb551 | |||
| 166201371c | |||
| d03ce79653 | |||
| 9f60ea9228 | |||
| fda2eb01ec | |||
| dda4f8da05 | |||
| 4d21045303 | |||
| 5316d922ab | |||
| 5dd0d7c2f3 | |||
| d4c40db011 | |||
| 7985b141c0 | |||
| 1d434f03dd | |||
| 062719adce | |||
| e954f2fe76 | |||
| eef23909f4 | |||
| c2868b5128 | |||
| 36246d3263 | |||
| ebf48b6062 | |||
| 641f08a042 | |||
| e67a7f2f01 | |||
| c548add86e | |||
| aa33643982 | |||
| 77edc576ea | |||
| 6a1ef7b409 | |||
| 1be4fea2f5 | |||
| c9fab9fa8f | |||
| 016ba13466 | |||
| d46b5a36b1 | |||
| 5975b71dcc | |||
| 78fe191845 | |||
| 2c53203473 | |||
| bcecdcf9ef | |||
| 5c956ba949 | |||
| c161a6d298 | |||
| 842d834c6d | |||
| 1416043529 | |||
| 3a10e6abb7 | |||
| 8fbbcabc57 | |||
| 1a57c5ca49 | |||
| 870b04bb28 | |||
| 777d36617f | |||
| 440732bfb9 | |||
| 2e3aed0038 | |||
| 72f6893660 | |||
| b0728746cd | |||
| 6a46d6dce7 | |||
| 25ec96492f | |||
| 28a113c132 | |||
| ebf6530c1f | |||
| bb83a87748 | |||
| 52cbe2a7ed | |||
| 1d7e2dc5b4 | |||
| 58f5a715f2 | |||
| 5c0951fc64 | |||
| 9af60b0dbe | |||
| 3d729996bb | |||
| 65927214ed | |||
| 35f11ccb83 | |||
| 30f84486fa | |||
| 19ba954c6b | |||
| 2589b24673 | |||
| e3eef8e23f | |||
| 2bc3e5221d | |||
| 96ff60e3ec | |||
| 923f499709 | |||
| 87977298e9 | |||
| 539050629d | |||
| ed64e2b846 | |||
| 8061f3d292 | |||
| 702869e1f8 | |||
| 46396153e3 | |||
| b707b27020 | |||
| 5c7bd5bb74 | |||
| 0ebcb79259 | |||
| f990d81ca2 | |||
| 07b95549fd | |||
| 53e63f4d6a | |||
| 65a2ad75dd | |||
| 607f0a7a4d | |||
| 8399c9e8a7 | |||
| 07ee1f906f | |||
| 4c3731da70 | |||
| e856af135c | |||
| ed5be9ec39 | |||
| 19ef964c08 | |||
| f2bb14bb17 | |||
| 840b427a7e | |||
| 9adcc812a3 | |||
| 57e8d9ac21 | |||
| 35fcf700e2 | |||
| 09df4d6475 | |||
| c477af2fad | |||
| 3d56faa361 | |||
| fc28b9cf04 | |||
| d86a9bfe94 | |||
| d754ad1eba | |||
| 57997bf184 | |||
| f6720db786 | |||
| 83335d01ef | |||
| 670f4f9ea5 | |||
| ae15665b2f | |||
| e8bea369b8 | |||
| f9a7c0c5c4 | |||
| 8d301bdbd1 | |||
| 986fde4724 | |||
| d01efd710e | |||
| 36d33d9f11 | |||
| 7b5dacf723 | |||
| d41e43fcb8 | |||
| f183e7566e | |||
| 2eab11561d | |||
| 5b7c5d0f1d | |||
| 9815f0efdf | |||
| bfa5042f07 | |||
| 52a1cd2817 | |||
| b232d6648c | |||
| 672b4b771e | |||
| dbdf9e415e | |||
| 8ac701f214 | |||
| fdac3b1433 | |||
| 7e30cef008 | |||
| 1709552ed4 | |||
| 0d33e18a88 | |||
| d3106a6576 | |||
| ac86ab23ad | |||
| acbf404780 | |||
| d533bd528d | |||
| 55b631cd9c | |||
| 241834feb8 | |||
| 96e8afca57 | |||
| 8755df31c9 | |||
| f48395dda1 | |||
| c397f7b1e5 | |||
| 8ecf54d8af | |||
| 97b02556ba | |||
| 49415eb4db | |||
| a008ff96ed | |||
| 1cd705665e | |||
| 9b0cdf18f5 | |||
| 7d6e056e6b | |||
| 30e8e89422 | |||
| a154b3fa39 | |||
| c6ab1ea75d | |||
| c2956adb4b | |||
| 8ea3a7b25c | |||
| 29f2c09a69 | |||
| a69cd3af61 | |||
| 458b38b2d9 | |||
| 3b82fbbd1a | |||
| a5d1842895 | |||
| 3f01207c29 | |||
| 1da28cd78d | |||
| 2a9cc506f0 | |||
| 223fbf39b2 | |||
| 8a6ee6d98c | |||
| 39b93997c2 | |||
| 3a6e364c70 | |||
| 87166564fc | |||
| 2078b0a934 | |||
| f7ee30f5dd | |||
| 4d45b22592 | |||
| bc99fba385 |
@@ -1,80 +0,0 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Deploy to K3s
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: localhost:5000/tiny-ci-runner:latest
|
||||
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
KUBECONFIG: /tmp/.kube/config
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- name: Write kubeconfig
|
||||
run: |
|
||||
mkdir -p /tmp/.kube
|
||||
cat << 'EOF' > /tmp/.kube/config
|
||||
${{ secrets.KUBECONFIG_DATA }}
|
||||
EOF
|
||||
chmod 600 /tmp/.kube/config
|
||||
|
||||
- name: Verify Kubernetes access
|
||||
run: |
|
||||
kubectl cluster-info
|
||||
kubectl get nodes
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone --depth=1 --branch master \
|
||||
https://git.tonesc.cn/tone/tonePage.git \
|
||||
/workspace/tone/tonePage
|
||||
cd /workspace/tone/tonePage
|
||||
git log -1 --oneline
|
||||
|
||||
- name: Build and push backend image
|
||||
run: |
|
||||
cd /workspace/tone/tonePage/apps/backend
|
||||
docker build -t localhost:5000/backend:${IMAGE_TAG} .
|
||||
docker push localhost:5000/backend:${IMAGE_TAG}
|
||||
|
||||
- name: Build and push frontend image
|
||||
run: |
|
||||
cd /workspace/tone/tonePage/apps/frontend
|
||||
docker build \
|
||||
--build-arg API_BASE="http://backend-service:3001" \
|
||||
-t localhost:5000/frontend:${IMAGE_TAG} .
|
||||
docker push localhost:5000/frontend:${IMAGE_TAG}
|
||||
|
||||
- name: Deploy to K3s
|
||||
run: |
|
||||
cd /workspace/tone/tonePage/apps/deploy
|
||||
|
||||
# 基础资源
|
||||
kubectl apply -f postgres-deployment.yaml
|
||||
kubectl apply -f backend-deployment.yaml
|
||||
kubectl apply -f frontend-deployment.yaml
|
||||
|
||||
# 更新镜像(触发滚动更新)
|
||||
kubectl set image deployment/backend \
|
||||
backend=localhost:5000/backend:${IMAGE_TAG}
|
||||
|
||||
kubectl set image deployment/frontend \
|
||||
frontend=localhost:5000/frontend:${IMAGE_TAG}
|
||||
|
||||
# 等待滚动完成
|
||||
kubectl rollout status deployment/backend --timeout=120s
|
||||
kubectl rollout status deployment/frontend --timeout=120s
|
||||
|
||||
- name: Post-deploy sanity check
|
||||
run: |
|
||||
kubectl get pods
|
||||
kubectl get svc
|
||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 tonecn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env.local
|
||||
@@ -1,23 +0,0 @@
|
||||
FROM node:22-alpine AS builder
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
RUN CI=true pnpm prune --prod
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -1,22 +0,0 @@
|
||||
import { IsEnum, IsString } from 'class-validator';
|
||||
import { BlogPermission } from 'src/blog/blog.permission.enum';
|
||||
|
||||
export class CreateBlogDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
slug: string;// 允许空串,但如果为空则需要手动设置为null,防止数据库唯一键冲突
|
||||
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
contentUrl: string;
|
||||
|
||||
@IsEnum(BlogPermission, { each: true, message: '请求类型错误' })
|
||||
permissions: BlogPermission[];
|
||||
|
||||
@IsString()
|
||||
password: string; // 允许空串
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SetBlogPasswordDto {
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { IsEnum, IsString } from 'class-validator';
|
||||
import { BlogPermission } from 'src/blog/blog.permission.enum';
|
||||
|
||||
export class UpdateBlogDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
contentUrl: string;
|
||||
|
||||
@IsEnum(BlogPermission, { each: true, message: '请求类型错误' })
|
||||
permissions: BlogPermission[];
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Resource } from "src/resource/entity/resource.entity";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
@Injectable()
|
||||
export class AdminResourceService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Resource)
|
||||
private readonly resourceRepository: Repository<Resource>,
|
||||
) { }
|
||||
|
||||
|
||||
async findAll() {
|
||||
return this.resourceRepository.find({
|
||||
order: {
|
||||
updatedAt: 'DESC',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Resource> {
|
||||
return this.resourceRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async create(data: Partial<Resource>): Promise<Resource> {
|
||||
const resource = this.resourceRepository.create(data);
|
||||
return this.resourceRepository.save(resource);
|
||||
}
|
||||
|
||||
async update(data: Partial<Resource>): Promise<Resource> {
|
||||
// const updateRes = await this.resourceRepository.update(id, data);
|
||||
// updateRes.affected
|
||||
// return this.resourceRepository.findOne({ where: { id } });
|
||||
return this.resourceRepository.save(data);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.resourceRepository.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { LoginByPasswordDto } from './dto/login.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UserSessionService } from 'src/auth/service/user-session.service';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { Request, Response } from 'express';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { AuthGuard } from './guards/auth.guard';
|
||||
import { SmsLoginDto } from './dto/sms-login.dto';
|
||||
import { SmsService } from 'src/sms/sms.service';
|
||||
import { UserSession } from 'src/auth/entity/user-session.entity';
|
||||
import { PasskeyService } from './service/passkey.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PasskeyLoginDto } from './dto/passkey-login.dto';
|
||||
import { AuthUser, CurrentUser } from './decorator/current-user.decorator';
|
||||
import { PasskeyRegisterDto } from './dto/passkey-register.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly userService: UserService,
|
||||
private readonly userSessionService: UserSessionService,
|
||||
private readonly smsService: SmsService,
|
||||
private readonly passkeyService: PasskeyService,
|
||||
) { }
|
||||
|
||||
private setUserSession(res: Response, session: UserSession) {
|
||||
res.cookie('session', session.sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
// 永不过期,不用设置maxAge
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
@Post('login/password')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({
|
||||
'min': { limit: 5, ttl: 60 * 1000 },
|
||||
'hour': { limit: 20, ttl: 60 * 60 * 1000 },
|
||||
'day': { limit: 50, ttl: 24 * 60 * 60 * 1000 }
|
||||
})
|
||||
async loginByPassword(
|
||||
@Body() loginDto: LoginByPasswordDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const { identifier, password } = loginDto;
|
||||
const session = await this.authService.loginWithPassword(identifier, password);
|
||||
this.setUserSession(res, session);
|
||||
return {
|
||||
user: await this.userService.findById(session.userId),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('login/sms')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({
|
||||
'day': { limit: 50, ttl: 24 * 60 * 60 * 1000 }
|
||||
})
|
||||
async loginBySms(
|
||||
@Body() dto: SmsLoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const { phone, code } = dto;
|
||||
await this.smsService.checkSms(phone, 'login', code);
|
||||
// 验证通过,(注册并)登陆
|
||||
const session = await this.authService.loginWithPhone(phone);
|
||||
this.setUserSession(res, session);
|
||||
return {
|
||||
user: await this.userService.findById(session.userId),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Post('passkey/login/options')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({
|
||||
'day': { limit: 20, ttl: 24 * 60 * 60 * 1000 }
|
||||
})
|
||||
async loginByPasskeyOptions(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const tempSessionId = uuidv4();
|
||||
const options = await this.passkeyService.getAuthenticationOptions(tempSessionId);
|
||||
|
||||
res.cookie('passkey_temp_session', tempSessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth/passkey/login',
|
||||
maxAge: 1 * 60 * 1000,
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
@Post('passkey/login')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({
|
||||
'day': { limit: 20, ttl: 24 * 60 * 60 * 1000 }
|
||||
})
|
||||
async loginByPasskey(
|
||||
@Req() req: Request,
|
||||
@Body() body: PasskeyLoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const tempSessionId = req.cookies?.passkey_temp_session;
|
||||
if (!tempSessionId) {
|
||||
throw new BadRequestException('登录失败,请重试');
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.passkeyService.login(tempSessionId, body.credentialResponse);
|
||||
|
||||
const session = await this.userSessionService.createSession(user.userId);
|
||||
|
||||
this.setUserSession(res, session);
|
||||
|
||||
return {
|
||||
user: await this.userService.findById(user.userId),
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
res.clearCookie('passkey_temp_session', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth/passkey/login',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('passkey/register/options')
|
||||
async getPasskeyRegisterOptions(
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
const { userId } = user;
|
||||
return this.passkeyService.getRegistrationOptions(userId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('passkey/register')
|
||||
async registerPasskey(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: PasskeyRegisterDto,
|
||||
) {
|
||||
const { userId } = user;
|
||||
const { credentialResponse, name } = dto;
|
||||
|
||||
const passkey = await this.passkeyService.register(userId, credentialResponse, name.trim());
|
||||
|
||||
return {
|
||||
id: passkey.id,
|
||||
name: passkey.name,
|
||||
createdAt: passkey.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('logout')
|
||||
async logout(@CurrentUser() user: AuthUser, @Res({ passthrough: true }) res: Response) {
|
||||
const { sessionId } = user;
|
||||
await this.userSessionService.invalidateSession(sessionId, '用户主动登出');
|
||||
res.clearCookie('session', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
})
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserSession } from 'src/auth/entity/user-session.entity';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { VerificationModule } from 'src/verification/verification.module';
|
||||
import { AuthGuard } from './guards/auth.guard';
|
||||
import { OptionalAuthGuard } from './guards/optional-auth.guard';
|
||||
import { SmsModule } from 'src/sms/sms.module';
|
||||
import { PasskeyCredential } from './entity/passkey-credential.entity';
|
||||
import { UserSessionService } from './service/user-session.service';
|
||||
import { PasskeyService } from './service/passkey.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
forwardRef(() => UserModule),
|
||||
TypeOrmModule.forFeature([UserSession, PasskeyCredential]),
|
||||
VerificationModule,
|
||||
SmsModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, UserSessionService, PasskeyService, AuthGuard, OptionalAuthGuard],
|
||||
exports: [AuthService, UserSessionService, PasskeyService, AuthGuard, OptionalAuthGuard],
|
||||
})
|
||||
export class AuthModule { }
|
||||
@@ -1,76 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { UserSessionService } from 'src/auth/service/user-session.service';
|
||||
import { BusinessException } from 'src/common/exceptions/business.exception';
|
||||
import { ErrorCode } from 'src/common/constants/error-codes';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly userSessionService: UserSessionService,
|
||||
) { }
|
||||
|
||||
async loginWithPassword(identifier: string, password: string) {
|
||||
// 依次使用邮箱、手机号、账号登陆(防止有大聪明给账号改成别人的邮箱或手机号)
|
||||
const user = await this.userService.findOne(
|
||||
[{ email: identifier }, { phone: identifier }, { username: identifier }],
|
||||
{
|
||||
withDeleted: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (user && user.deletedAt !== null) {
|
||||
throw new BusinessException({
|
||||
message: '该账号注销中',
|
||||
code: ErrorCode.USER_ACCOUNT_DEACTIVATED,
|
||||
});
|
||||
}
|
||||
|
||||
if (user === null || !user.password_hash || !user.salt) {
|
||||
throw new BusinessException({
|
||||
message: '账户或密码错误',
|
||||
code: ErrorCode.AUTH_INVALID_CREDENTIALS
|
||||
});
|
||||
}
|
||||
|
||||
// 判断密码是否正确
|
||||
const hashedPassword = this.hashPassword(password, user.salt);
|
||||
if (hashedPassword !== user.password_hash) {
|
||||
throw new BusinessException({
|
||||
message: '账户或密码错误',
|
||||
code: ErrorCode.AUTH_INVALID_CREDENTIALS
|
||||
});
|
||||
}
|
||||
|
||||
const { userId } = user;
|
||||
|
||||
return this.userSessionService.createSession(userId);
|
||||
}
|
||||
|
||||
async loginWithPhone(phone: string) {
|
||||
// 判断用户是否存在,若不存在则进行注册
|
||||
let user = await this.userService.findOne({ phone }, { withDeleted: true });
|
||||
if (user && user.deletedAt !== null) {
|
||||
throw new BadRequestException('该账号注销中,请使用其他手机号');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// 执行注册操作
|
||||
user = await this.userService.register({ phone });
|
||||
}
|
||||
|
||||
if (!user || !user.userId) {
|
||||
// 注册失败或用户信息错误
|
||||
throw new BadRequestException('请求失败,请稍后再试');
|
||||
}
|
||||
|
||||
return this.userSessionService.createSession(user.userId);
|
||||
}
|
||||
|
||||
private hashPassword(password: string, salt: string): string {
|
||||
return createHash('sha256').update(`${password}${salt}`).digest('hex');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
export interface AuthUser {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
const request = ctx.switchToHttp().getRequest<Request>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -1,37 +0,0 @@
|
||||
import { IsEnum, IsString, Length, ValidateIf } from 'class-validator';
|
||||
|
||||
// export class LoginDto {
|
||||
// @IsEnum(['password', 'phone', 'email'], { message: '请求类型错误' })
|
||||
// type: 'password' | 'phone' | 'email';
|
||||
|
||||
// @ValidateIf((o) => o.type === 'password')
|
||||
|
||||
// account?: string;
|
||||
|
||||
|
||||
|
||||
// @ValidateIf((o) => o.type === 'phone')
|
||||
// @IsString({ message: '手机号必须输入' })
|
||||
// @Length(11, 11, { message: '手机号异常' }) // 中国大陆,11位数字
|
||||
// phone?: string;
|
||||
|
||||
// @ValidateIf((o) => o.type === 'email')
|
||||
// @IsString({ message: '邮箱必须输入' })
|
||||
// @Length(6, 254, { message: '邮箱异常' }) // RFC 5321
|
||||
// email?: string;
|
||||
|
||||
// @ValidateIf((o) => o.type === 'phone' || o.type === 'email')
|
||||
// @IsString({ message: '验证码必须输入' })
|
||||
// @Length(6, 6, { message: '验证码异常' }) // 6位数字
|
||||
// code?: string;
|
||||
// }
|
||||
|
||||
export class LoginByPasswordDto {
|
||||
@IsString({ message: '账户必须输入' })
|
||||
@Length(1, 254, { message: '账户异常' }) // 用户名、邮箱、手机号
|
||||
identifier: string;
|
||||
|
||||
@IsString({ message: '密码必须输入' })
|
||||
@Length(6, 32, { message: '密码异常' }) // 6-32位
|
||||
password: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsObject } from "class-validator";
|
||||
|
||||
export class PasskeyLoginDto {
|
||||
@IsObject()
|
||||
credentialResponse: any;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IsObject, IsString } from "class-validator";
|
||||
|
||||
export class PasskeyRegisterDto {
|
||||
@IsObject()
|
||||
credentialResponse: any;
|
||||
|
||||
@IsString({ message: '通行证名称只能是字符串' })
|
||||
name: string;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IsPhoneNumber, Matches } from "class-validator";
|
||||
|
||||
export class SmsLoginDto {
|
||||
@IsPhoneNumber('CN', {
|
||||
message: '请输入有效的中国大陆手机号',
|
||||
})
|
||||
phone: string;
|
||||
|
||||
@Matches(/^\d{6}$/, {
|
||||
message: '验证码必须是6位数字',
|
||||
})
|
||||
code: string;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { User } from "src/user/entities/user.entity";
|
||||
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
|
||||
|
||||
@Entity()
|
||||
@Index(['user'])
|
||||
export class PasskeyCredential {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
// 关联用户
|
||||
@ManyToOne(() => User, user => user.passkeys, { onDelete: 'CASCADE' })
|
||||
user: User;
|
||||
|
||||
// WebAuthn 必需字段
|
||||
@Column({ length: 255 })
|
||||
name: string; // 用户自定义名称,如 "iPhone", "工作笔记本"
|
||||
|
||||
@Column({ unique: true })
|
||||
credentialId: string; // Base64URL 编码的 credentialId(唯一标识)
|
||||
|
||||
@Column({ type: 'text' })
|
||||
publicKey: string; // Base64URL 编码的公钥(SPKI 格式)
|
||||
|
||||
@Column({ type: 'int' })
|
||||
signCount: number; // 防重放攻击,每次签名递增
|
||||
|
||||
// 是否已验证(注册时验证,登录时更新)
|
||||
@Column({ default: false })
|
||||
verified: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// auth.guard.ts
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { UserSessionService } from 'src/auth/service/user-session.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private userSessionService: UserSessionService,
|
||||
) { }
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
// 从 Cookie 读取 session
|
||||
const sessionId = request.cookies?.['session'];
|
||||
if (!sessionId) {
|
||||
throw new UnauthorizedException('登陆凭证无效,请重新登陆');
|
||||
}
|
||||
|
||||
// 验证 session
|
||||
const session = await this.userSessionService.getSession(sessionId);
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('登陆凭证无效,请重新登陆');
|
||||
}
|
||||
|
||||
const { userId } = session;
|
||||
request.user = {
|
||||
sessionId,
|
||||
userId,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "./auth.guard";
|
||||
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard extends AuthGuard {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
return await super.canActivate(context);
|
||||
} catch (error) {
|
||||
// 验证失败时,req.user = null,但允许继续
|
||||
const request = context.switchToHttp().getRequest();
|
||||
request.user = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { PasskeyCredential } from "../entity/passkey-credential.entity";
|
||||
import { Repository } from "typeorm";
|
||||
import { User } from "src/user/entities/user.entity";
|
||||
import { randomBytes } from 'crypto';
|
||||
import { generateAuthenticationOptions, GenerateAuthenticationOptionsOpts, generateRegistrationOptions, GenerateRegistrationOptionsOpts, VerifiedAuthenticationResponse, VerifiedRegistrationResponse, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
|
||||
interface ChallengeEntry {
|
||||
value: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class MemoryChallengeStore {
|
||||
private store = new Map<string, ChallengeEntry>();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private ttlMs: number = 5 * 60 * 100) {
|
||||
this.startCleanup();
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
this.store.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): string | null {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
private startCleanup(): void {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.store.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60_000); // 每分钟清理一次
|
||||
}
|
||||
|
||||
stopCleanup(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registrationChallenges = new MemoryChallengeStore(5 * 60 * 1000); // 5 分钟过期
|
||||
const authenticationChallenges = new MemoryChallengeStore(5 * 60 * 1000);
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class PasskeyService implements OnModuleDestroy {
|
||||
|
||||
private readonly rpID: string;
|
||||
private readonly origin: string;
|
||||
private readonly rpName: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PasskeyCredential)
|
||||
private readonly passkeyRepo: Repository<PasskeyCredential>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {
|
||||
this.rpID = process.env.WEBAUTHN_RP_ID;
|
||||
this.origin = process.env.WEBAUTHN_ORIGIN;
|
||||
this.rpName = process.env.WEBAUTHN_RP_NAME;
|
||||
|
||||
if (!this.rpID || !this.origin || !this.rpName) {
|
||||
throw new Error('Missing required env: WEBAUTHN_RP_ID or WEBAUTHN_ORIGIN');
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
registrationChallenges.stopCleanup();
|
||||
authenticationChallenges.stopCleanup();
|
||||
}
|
||||
|
||||
private generateChallenge(length: number = 32): string {
|
||||
return randomBytes(length).toString('base64');
|
||||
}
|
||||
|
||||
async getRegistrationOptions(userId: string) {
|
||||
const user = await this.userRepository.findOneBy({ userId });
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
const challenge = this.generateChallenge();
|
||||
|
||||
const opts: GenerateRegistrationOptionsOpts = {
|
||||
rpName: this.rpName,
|
||||
rpID: this.rpID,
|
||||
userID: Buffer.from(userId),
|
||||
userName: user.username || 'user',
|
||||
userDisplayName: user.nickname || 'User',
|
||||
challenge,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required', // 必须是可发现凭证(Passkey)
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
supportedAlgorithmIDs: [-7], // ES256
|
||||
timeout: 60000,
|
||||
};
|
||||
|
||||
const options = await generateRegistrationOptions(opts);
|
||||
registrationChallenges.set(userId, options.challenge)
|
||||
return options;
|
||||
}
|
||||
|
||||
async register(userId: string, credentialResponse: any, name: string): Promise<PasskeyCredential> {
|
||||
const expectedChallenge = registrationChallenges.get(userId);
|
||||
if (!expectedChallenge) {
|
||||
throw new BadRequestException('注册失败,请重试');
|
||||
}
|
||||
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.origin,
|
||||
expectedRPID: this.rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new BadRequestException('注册失败');
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException('注册失败');
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
if (!credential) {
|
||||
throw new InternalServerErrorException('服务器内部错误');
|
||||
}
|
||||
|
||||
// 保存凭证到数据库
|
||||
const passkey = this.passkeyRepo.create({
|
||||
user: { userId } as User,
|
||||
name: name || '新的通行证',
|
||||
credentialId: credential.id,
|
||||
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
|
||||
signCount: credential.counter,
|
||||
verified: true,
|
||||
});
|
||||
|
||||
await this.passkeyRepo.save(passkey);
|
||||
registrationChallenges.delete(userId);
|
||||
|
||||
return passkey;
|
||||
}
|
||||
|
||||
async getAuthenticationOptions(sessionId: string) {
|
||||
const challenge = this.generateChallenge();
|
||||
const opts: GenerateAuthenticationOptionsOpts = {
|
||||
rpID: this.rpID,
|
||||
challenge,
|
||||
timeout: 60000,
|
||||
userVerification: 'preferred',
|
||||
};
|
||||
|
||||
const options = await generateAuthenticationOptions(opts);
|
||||
authenticationChallenges.set(sessionId, options.challenge);
|
||||
return options;
|
||||
}
|
||||
|
||||
async login(sessionId: string, credentialResponse: any): Promise<User> {
|
||||
const expectedChallenge = authenticationChallenges.get(sessionId);
|
||||
if (!expectedChallenge) {
|
||||
throw new BadRequestException('认证失败,请重试');
|
||||
}
|
||||
|
||||
const credentialId = credentialResponse.id;
|
||||
const passkey = await this.passkeyRepo.findOne({
|
||||
where: { credentialId, verified: true },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new NotFoundException('未找到可用的通行证');
|
||||
}
|
||||
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.origin,
|
||||
expectedRPID: this.rpID,
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
|
||||
counter: passkey.signCount,
|
||||
},
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new BadRequestException('认证失败');
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException('认证失败');
|
||||
}
|
||||
|
||||
const newSignCount = verification.authenticationInfo.newCounter;
|
||||
if (newSignCount !== passkey.signCount) {
|
||||
passkey.signCount = newSignCount;
|
||||
await this.passkeyRepo.save(passkey);
|
||||
}
|
||||
|
||||
authenticationChallenges.delete(sessionId);
|
||||
return passkey.user;
|
||||
}
|
||||
|
||||
async listUserPasskeys(userId: string): Promise<PasskeyCredential[]> {
|
||||
return this.passkeyRepo.find({
|
||||
where: { user: { userId }, verified: true },
|
||||
select: ['id', 'name', 'createdAt'],
|
||||
});
|
||||
}
|
||||
|
||||
async removePasskey(userId: string, passkeyId: string): Promise<void> {
|
||||
const result = await this.passkeyRepo.delete({
|
||||
id: passkeyId,
|
||||
user: { userId },
|
||||
});
|
||||
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException('未找到对应的通行证');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UserSession } from '../entity/user-session.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class UserSessionService {
|
||||
constructor(
|
||||
@InjectRepository(UserSession)
|
||||
private readonly userSessionRepository: Repository<UserSession>,
|
||||
) { }
|
||||
|
||||
async createSession(userId: string): Promise<UserSession> {
|
||||
const session = this.userSessionRepository.create({
|
||||
userId,
|
||||
});
|
||||
return this.userSessionRepository.save(session);
|
||||
}
|
||||
|
||||
async getSession(sessionId: string) {
|
||||
const session = await this.userSessionRepository.findOne({
|
||||
where: {
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async invalidateSession(sessionId: string, reason?: string): Promise<void> {
|
||||
await this.userSessionRepository.update(
|
||||
{ sessionId, deletedAt: null },
|
||||
{
|
||||
deletedAt: new Date(),
|
||||
disabledReason: reason || null,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export enum BlogPermission {
|
||||
Public = 'Public',
|
||||
ByPassword = 'ByPassword',
|
||||
List = 'List',
|
||||
AllowComments = 'AllowComments',
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Blog } from './entity/Blog.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BlogComment } from './entity/BlogComment.entity';
|
||||
import { BlogPermission } from './blog.permission.enum';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class BlogService {
|
||||
constructor(
|
||||
@InjectRepository(Blog)
|
||||
private readonly blogRepository: Repository<Blog>,
|
||||
@InjectRepository(BlogComment)
|
||||
private readonly blogCommentRepository: Repository<BlogComment>,
|
||||
) { }
|
||||
|
||||
async list(
|
||||
option: {
|
||||
withAll?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return (
|
||||
await this.blogRepository.find({
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(i) => option.withAll || i.permissions.includes(BlogPermission.List),
|
||||
)
|
||||
.map((i) => {
|
||||
if (option.withAll) {
|
||||
return i;
|
||||
}
|
||||
|
||||
const { createdAt, updatedAt, id, title, viewCount, description, slug } = i;
|
||||
return {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
id,
|
||||
title,
|
||||
slug,
|
||||
viewCount,
|
||||
description,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: Partial<Blog> & { password: string }) {
|
||||
const { password, ...blog } = dto;
|
||||
if (blog.permissions.includes(BlogPermission.ByPassword)) {
|
||||
if (password) {
|
||||
blog.password_hash = createHash('sha256')
|
||||
.update(`${password}`)
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
if (typeof blog.slug === 'string' && blog.slug.trim().length === 0) {
|
||||
blog.slug = null;
|
||||
}
|
||||
|
||||
const newBlog = this.blogRepository.create(blog);
|
||||
return this.blogRepository.save(newBlog);
|
||||
}
|
||||
|
||||
async setPassword(id: string, password: string) {
|
||||
const blog = await this.findById(id);
|
||||
if (!blog) {
|
||||
throw new Error('博客不存在');
|
||||
}
|
||||
|
||||
return (
|
||||
(
|
||||
await this.blogRepository.update(id, {
|
||||
...blog,
|
||||
password_hash: this.hashPassword(password),
|
||||
})
|
||||
).affected > 0
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: string, blog: Partial<Blog>) {
|
||||
await this.blogRepository.update(id, blog);
|
||||
return this.blogRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const blog = await this.blogRepository.findOneBy({ id });
|
||||
if (!blog) return null;
|
||||
return this.blogRepository.softRemove(blog);
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return await this.blogRepository.findOneBy({ id });
|
||||
}
|
||||
|
||||
async findBySlug(slug: string) {
|
||||
return this.blogRepository.findOne({
|
||||
where: { slug }
|
||||
})
|
||||
}
|
||||
|
||||
async incrementViewCount(id: string) {
|
||||
await this.blogRepository.increment({ id }, 'viewCount', 1);
|
||||
}
|
||||
|
||||
async getComments(blog: Blog) {
|
||||
const comments = await this.blogCommentRepository.find({
|
||||
where: { blog: { id: blog.id } },
|
||||
relations: ['user'],
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
return comments.map((comment) => {
|
||||
const { user, ...rest } = comment;
|
||||
delete rest.blog;
|
||||
return {
|
||||
...rest,
|
||||
user: user
|
||||
? {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async createComment(comment: Partial<BlogComment>) {
|
||||
const newComment = this.blogCommentRepository.create(comment);
|
||||
const savedComment = await this.blogCommentRepository.save(newComment, {});
|
||||
const { user, ...commentWithoutBlog } = savedComment;
|
||||
delete commentWithoutBlog.blog;
|
||||
return {
|
||||
...commentWithoutBlog,
|
||||
user: user
|
||||
? {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
hashPassword(password: string) {
|
||||
return createHash('sha256').update(`${password}`).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CaptchaController } from './captcha.controller';
|
||||
|
||||
describe('CaptchaController', () => {
|
||||
let controller: CaptchaController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CaptchaController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<CaptchaController>(CaptchaController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { GetCaptchaDto } from './dto/get-captcha.dto';
|
||||
|
||||
@Controller('captcha')
|
||||
export class CaptchaController {
|
||||
@Get()
|
||||
async getCaptcha(dto: GetCaptchaDto) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CaptchaService } from './captcha.service';
|
||||
import { CaptchaController } from './captcha.controller';
|
||||
import { CaptchaRateLimitService } from './service/rate-limit';
|
||||
|
||||
@Module({
|
||||
providers: [CaptchaService, CaptchaRateLimitService],
|
||||
controllers: [CaptchaController],
|
||||
imports: [],
|
||||
})
|
||||
export class CaptchaModule { }
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CaptchaService } from './captcha.service';
|
||||
|
||||
describe('CaptchaService', () => {
|
||||
let service: CaptchaService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [CaptchaService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CaptchaService>(CaptchaService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ErrorCode } from 'src/common/constants/error-codes';
|
||||
import { BusinessException } from 'src/common/exceptions/business.exception';
|
||||
|
||||
export enum CaptchaContext {
|
||||
SEND_SMS = 'send_sms',
|
||||
PASSKEY = 'passkey',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
public async generate(context: CaptchaContext, ip: string, userId?: string) {
|
||||
await this.checkRateLimit(ip, context)
|
||||
}
|
||||
|
||||
public async verify(token: string, ip: string, userId?: string) {
|
||||
|
||||
}
|
||||
|
||||
private async checkRateLimit(ip: string, context: CaptchaContext) {
|
||||
/** @todo */
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.CAPTCHA_RARE_LIMIT,
|
||||
message: '服务器处理不过来了,过会儿再试试吧',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { IsEnum, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
export enum CaptchaContext {
|
||||
SEND_SMS = 'send_sms',
|
||||
PASSKEY = 'passkey',
|
||||
}
|
||||
|
||||
export class GetCaptchaDto {
|
||||
|
||||
@IsEnum(CaptchaContext, { message: '无效的context' })
|
||||
context: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: 'userId不合法' })
|
||||
userId?: string;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export class CaptchaRateLimitService {
|
||||
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RolesGuard } from './guard/roles.guard';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
|
||||
@Module({
|
||||
providers: [RolesGuard],
|
||||
imports: [UserModule],
|
||||
exports: [RolesGuard],
|
||||
})
|
||||
export class CommonModule { }
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* 全局业务错误码规范:
|
||||
* - 每个模块分配一个 1000 起始的段(如 USER: -1000~1999, AUTH: -2000~2999)
|
||||
* - 代码结构:{ 模块名大写 }_{ 错误语义 }
|
||||
*/
|
||||
|
||||
export const ErrorCode = {
|
||||
// 通用错误(0 ~ 999)
|
||||
COMMON_INTERNAL_ERROR: -1,
|
||||
COMMON_INVALID_PARAM: -2,
|
||||
COMMON_NOT_FOUND: -3,
|
||||
|
||||
// 用户模块(1000 ~ 1999)
|
||||
USER_NOT_FOUND: -1001,
|
||||
USER_ALREADY_EXISTS: -1002,
|
||||
USER_ACCOUNT_DISABLED: -1003,
|
||||
USER_FIND_OPTIONS_EMPTY: -1004,
|
||||
USER_ACCOUNT_DEACTIVATED: -1005,
|
||||
|
||||
// 认证模块
|
||||
AUTH_INVALID_CREDENTIALS: -2001,
|
||||
AUTH_PASSKEY_NOT_REGISTERED: -2002,
|
||||
AUTH_SESSION_EXPIRED: -2003,
|
||||
|
||||
// 博客模块
|
||||
BLOG_NOT_FOUND: -3001,
|
||||
BLOG_PERMISSION_DENIED: -3002,
|
||||
|
||||
// 验证模块
|
||||
CAPTCHA_RARE_LIMIT: -4001,
|
||||
|
||||
// 通知模块
|
||||
NOTIFICATION_SEND_FAILED: -5001,
|
||||
|
||||
// Sms模块
|
||||
SMS_CODE_INCORRECT: -6001,
|
||||
SMS_CODE_EXPIRED: -6002,
|
||||
|
||||
// 资源模块
|
||||
RESOURCE_UPLOAD_FAILED: -7001,
|
||||
RESOURCE_NOT_FOUND: -7002,
|
||||
|
||||
// 管理员模块
|
||||
ADMIN_FORBIDDEN: -8001,
|
||||
} as const;
|
||||
|
||||
export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode];
|
||||
@@ -1,22 +0,0 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class BusinessException {
|
||||
|
||||
public statusCode: HttpStatus;
|
||||
public message: string;
|
||||
public code: number;
|
||||
public data: any;
|
||||
|
||||
constructor(args: {
|
||||
statusCode?: HttpStatus,
|
||||
message?: string,
|
||||
code?: number,
|
||||
data?: any,
|
||||
}) {
|
||||
const { statusCode, message, code, data } = args;
|
||||
this.statusCode = statusCode || HttpStatus.BAD_REQUEST;
|
||||
this.message = message || '请求错误';
|
||||
this.code = code || -1;
|
||||
this.data = data || null;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ArgumentsHost, ExceptionFilter, HttpException, HttpStatus, Logger } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { BusinessException } from "../exceptions/business.exception";
|
||||
|
||||
export class GlobalExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: any, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let errorResponse = {
|
||||
success: false,
|
||||
message: '服务器内部错误',
|
||||
code: -1,
|
||||
data: null as any,
|
||||
};
|
||||
|
||||
if (exception instanceof BusinessException) {
|
||||
statusCode = exception.statusCode;
|
||||
const { message, code, data } = exception;
|
||||
errorResponse = {
|
||||
...errorResponse,
|
||||
message, code, data,
|
||||
}
|
||||
} else if (exception instanceof HttpException) {
|
||||
// 当HttpException传入类型为string时,响应data为null,message为传入的string
|
||||
// 其他请况(object/number),响应为传入数据,message为HttpException的错误码
|
||||
statusCode = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse() as Record<string, any>;
|
||||
if (exceptionResponse.message) {
|
||||
errorResponse.message = exceptionResponse.message;
|
||||
} else {
|
||||
errorResponse.message = '请求失败';
|
||||
errorResponse.data = exceptionResponse;
|
||||
}
|
||||
|
||||
if (statusCode === HttpStatus.UNAUTHORIZED && request.cookies?.['session']) {
|
||||
response.clearCookie('session', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
if (statusCode === HttpStatus.TOO_MANY_REQUESTS) {
|
||||
errorResponse.message = '请求过于频繁,请稍后再试';
|
||||
}
|
||||
} else {
|
||||
Logger.warn(exception, request.path);
|
||||
}
|
||||
|
||||
response.status(statusCode).json(errorResponse);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { AuthUser } from 'src/auth/decorator/current-user.decorator';
|
||||
import { Role } from 'src/auth/role.enum';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
|
||||
private logger = new Logger(RolesGuard.name);
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private readonly userService: UserService,
|
||||
) { }
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[] | undefined>(
|
||||
'roles',
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authUser = request.user as AuthUser;
|
||||
|
||||
if (!authUser) {
|
||||
this.logger.warn(
|
||||
`Path: ${request.path} has RolesGuard enabled, but it seems AuthGuard was forgotten.`
|
||||
)
|
||||
throw new InternalServerErrorException('服务器内部错误');
|
||||
}
|
||||
|
||||
const { userId } = authUser;
|
||||
const user = await this.userService.findOne({ userId })
|
||||
if (!user) {
|
||||
this.logger.warn(
|
||||
`UserId: ${user.userId} has a valid login credential, but the user information does not exist.`
|
||||
)
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
if (!requiredRoles.some((role) => user.roles.includes(role))) {
|
||||
throw new ForbiddenException('权限不足');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AuthUser } from "src/auth/decorator/current-user.decorator";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: Number(process.env.DATABASE_PORT ?? 5432),
|
||||
username: process.env.DATABASE_USERNAME,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
database: process.env.DATABASE_NAME,
|
||||
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
|
||||
entities: ['dist/**/*.entity.js'],
|
||||
migrations: ['dist/migrations/*.js'],
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddSlugToBlog1766809565876 implements MigrationInterface {
|
||||
name = 'AddSlugToBlog1766809565876'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "blog" ADD "slug" character varying`);
|
||||
await queryRunner.query(`ALTER TABLE "blog" ADD CONSTRAINT "UQ_0dc7e58d73a1390874a663bd599" UNIQUE ("slug")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "blog" DROP CONSTRAINT "UQ_0dc7e58d73a1390874a663bd599"`);
|
||||
await queryRunner.query(`ALTER TABLE "blog" DROP COLUMN "slug"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { OssService } from './oss.service';
|
||||
import { AuthGuard } from 'src/auth/guards/auth.guard';
|
||||
import { AuthUser, CurrentUser } from 'src/auth/decorator/current-user.decorator';
|
||||
|
||||
@Controller('oss')
|
||||
export class OssController {
|
||||
constructor(private readonly ossService: OssService) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('sts')
|
||||
async getStsToken(@CurrentUser() user: AuthUser) {
|
||||
const { userId } = user;
|
||||
return {
|
||||
...(await this.ossService.getStsToken(`${userId}`)),
|
||||
userId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PublicResource, Resource } from './entity/resource.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class ResourceService {
|
||||
constructor(
|
||||
@InjectRepository(Resource)
|
||||
private readonly resourceRepository: Repository<Resource>,
|
||||
) { }
|
||||
|
||||
async findAll(): Promise<PublicResource[]> {
|
||||
return this.resourceRepository.find({
|
||||
select: ['id', 'title', 'description', 'imageUrl', 'link', 'tags'],
|
||||
order: {
|
||||
updatedAt: 'DESC',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsPhoneNumber } from "class-validator";
|
||||
|
||||
export class SendLoginSmsDto {
|
||||
@IsPhoneNumber('CN', { message: '请输入有效的中国大陆手机号' })
|
||||
phone: string;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity()
|
||||
@Index('IDX_SMS_PHONE_TYPE', ['phone', 'type'])
|
||||
@Index('IDX_SMS_EXPIRED', ['expiredAt'])
|
||||
export class SmsRecord {
|
||||
@PrimaryGeneratedColumn('identity')
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
phone: string;
|
||||
|
||||
@Column()
|
||||
type: string;
|
||||
|
||||
@Column()
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'smallint', default: 0 })
|
||||
tryCount: number;
|
||||
|
||||
@CreateDateColumn({ precision: 3 })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', precision: 3 })
|
||||
expiredAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', precision: 3, nullable: true })
|
||||
usedAt: Date;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SmsController } from './sms.controller';
|
||||
|
||||
describe('SmsController', () => {
|
||||
let controller: SmsController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SmsController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SmsController>(SmsController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { SendLoginSmsDto } from './dto/send-login-sms.dto';
|
||||
import { SmsService } from './sms.service';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
@Controller('sms')
|
||||
export class SmsController {
|
||||
|
||||
constructor(private readonly smsService: SmsService) { }
|
||||
|
||||
@Post('send/login')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({
|
||||
'min': { limit: 3, ttl: 60 * 1000 },
|
||||
'hour': { limit: 10, ttl: 60 * 60 * 1000 },
|
||||
'day': { limit: 20, ttl: 24 * 60 * 60 * 1000 }
|
||||
})
|
||||
async sendLoginSms(@Body() dto: SendLoginSmsDto) {
|
||||
await this.smsService.sendSms(dto.phone, 'login');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
import { SmsController } from './sms.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { SmsRecord } from './entity/sms-record.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SmsRecord])],
|
||||
providers: [SmsService],
|
||||
controllers: [SmsController],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule { }
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
describe('SmsService', () => {
|
||||
let service: SmsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [SmsService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SmsService>(SmsService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import Dypnsapi, * as $Dypnsapi from '@alicloud/dypnsapi20170525';
|
||||
import * as $OpenApi from '@alicloud/openapi-client';
|
||||
import { randomInt } from 'crypto';
|
||||
import { MoreThan, Repository } from 'typeorm';
|
||||
import { SmsRecord } from './entity/sms-record.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BusinessException } from 'src/common/exceptions/business.exception';
|
||||
import { ErrorCode } from 'src/common/constants/error-codes';
|
||||
|
||||
const LoginSmsExpiredMin = 5;
|
||||
const LoginSmsMaxTryCount = 5;
|
||||
|
||||
const devMode = process.env.NODE_ENV !== 'production';
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
|
||||
private logger = new Logger(SmsService.name);
|
||||
|
||||
private client: Dypnsapi;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SmsRecord)
|
||||
private readonly smsRecordRepository: Repository<SmsRecord>
|
||||
) {
|
||||
const config = new $OpenApi.Config({})
|
||||
config.accessKeyId = process.env.ALIYUN_ACCESS_KEY_ID;
|
||||
config.accessKeySecret = process.env.ALIYUN_ACCESS_KEY_SECRET;
|
||||
|
||||
this.client = new Dypnsapi(config as any);
|
||||
}
|
||||
|
||||
private generateSmsCode(): string {
|
||||
// 生成 0 到 999999 的随机整数,补零到 6 位
|
||||
const code = randomInt(0, 1_000_000);
|
||||
return code.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
async checkSendSmsLimit(phone: string, type: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000);
|
||||
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// 1. 检查 1 分钟内是否已发送
|
||||
const recentRecord = await this.smsRecordRepository.findOne({
|
||||
where: {
|
||||
phone,
|
||||
type,
|
||||
createdAt: MoreThan(twentyFourHoursAgo),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (recentRecord && recentRecord.createdAt > oneMinuteAgo) {
|
||||
throw new BusinessException({ message: '操作太快了,稍后再重试吧' }); // 距离上一条不足 1 分钟
|
||||
}
|
||||
|
||||
// 2. 检查 24 小时内是否超过 5 条
|
||||
const count = await this.smsRecordRepository.count({
|
||||
where: {
|
||||
phone,
|
||||
type,
|
||||
createdAt: MoreThan(twentyFourHoursAgo),
|
||||
},
|
||||
});
|
||||
|
||||
if (count >= 5) {
|
||||
throw new BusinessException({ message: '操作太快了,稍后再重试吧' }); // 24 小时超过 5 条
|
||||
}
|
||||
}
|
||||
|
||||
async sendSms(phone: string, type: 'login') {
|
||||
if (type === 'login') {
|
||||
// 检查限流
|
||||
await this.checkSendSmsLimit(phone, type);
|
||||
|
||||
// 生成
|
||||
const code = this.generateSmsCode();
|
||||
const smsRecord = this.smsRecordRepository.create({
|
||||
phone,
|
||||
type,
|
||||
code,
|
||||
expiredAt: new Date(Date.now() + LoginSmsExpiredMin * 60 * 1000),
|
||||
});
|
||||
|
||||
// 发送
|
||||
const request = new $Dypnsapi.SendSmsVerifyCodeRequest({});
|
||||
request.phoneNumber = phone;
|
||||
request.signName = '速通互联验证码';
|
||||
request.templateCode = '100001';
|
||||
request.templateParam = JSON.stringify({
|
||||
code,
|
||||
min: `${LoginSmsExpiredMin}`,
|
||||
})
|
||||
|
||||
|
||||
let success: boolean = false;
|
||||
|
||||
if (devMode) {
|
||||
success = true;
|
||||
this.logger.debug(`${phone}:${code}`)
|
||||
} else {
|
||||
await this.client.sendSmsVerifyCode(request).then(a => {
|
||||
success = a.body?.success || false;
|
||||
}, err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
if (success) {
|
||||
this.smsRecordRepository.save(smsRecord).catch(e => {
|
||||
this.logger.warn(e, 'sendSms:saveRecord');
|
||||
})
|
||||
}
|
||||
|
||||
return success;
|
||||
} else {
|
||||
throw new InternalServerErrorException('未知的Sms类型');
|
||||
}
|
||||
}
|
||||
|
||||
async checkSms(phone: string, type: 'login', code: string) {
|
||||
if (type === 'login') {
|
||||
const now = new Date();
|
||||
|
||||
const record = await this.smsRecordRepository.findOne({
|
||||
where: {
|
||||
phone,
|
||||
type,
|
||||
expiredAt: MoreThan(now),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_EXPIRED,
|
||||
message: '验证码已失效,请重新获取',
|
||||
})
|
||||
}
|
||||
|
||||
// 检查被用过没
|
||||
if (record.usedAt !== null) {
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_EXPIRED,
|
||||
message: '验证码已失效,请重新获取',
|
||||
})
|
||||
}
|
||||
|
||||
// 检查尝试次数
|
||||
if (record.tryCount >= LoginSmsMaxTryCount) {
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_EXPIRED,
|
||||
message: '验证码已失效,请重新获取',
|
||||
})
|
||||
}
|
||||
|
||||
// 检查是否匹配
|
||||
if (record.code !== code) {
|
||||
// 增加尝试次数
|
||||
record.tryCount = (record.tryCount || 0) + 1;
|
||||
await this.smsRecordRepository.save(record);
|
||||
|
||||
if (record.tryCount >= LoginSmsMaxTryCount) {
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_EXPIRED,
|
||||
message: '验证码已失效,请重新获取',
|
||||
})
|
||||
}
|
||||
|
||||
throw new BusinessException({
|
||||
code: ErrorCode.SMS_CODE_INCORRECT,
|
||||
message: '验证码不对的喔~',
|
||||
})
|
||||
}
|
||||
|
||||
record.usedAt = new Date();
|
||||
await this.smsRecordRepository.save(record);
|
||||
} else {
|
||||
throw new InternalServerErrorException('未知的Sms类型');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Body, Controller, Get, Put, Request, UseGuards } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { UpdateUserPasswordDto } from './dto/update-user-password.dto';
|
||||
import { AuthGuard } from 'src/auth/guards/auth.guard';
|
||||
import { AuthUser, CurrentUser } from 'src/auth/decorator/current-user.decorator';
|
||||
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('me')
|
||||
async getMe(@CurrentUser() user: AuthUser) {
|
||||
return this.userService.findById(user.userId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Put('password')
|
||||
async update(@CurrentUser() user: AuthUser, @Body() dto: UpdateUserPasswordDto): Promise<null> {
|
||||
await this.userService.setPassword(user.userId, dto.password.trim());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SendVerificationCodeDto } from './dto/send-verification-code.dto';
|
||||
import { VerificationService } from './verification.service';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
@Controller('verification')
|
||||
export class VerificationController {
|
||||
constructor(private readonly verificationService: VerificationService) { }
|
||||
|
||||
// @Post('send')
|
||||
// @UseGuards(ThrottlerGuard)
|
||||
// @Throttle({ default: { limit: 20, ttl: 60000 } })
|
||||
// async sendVerificationCode(@Body() dto: SendVerificationCodeDto) {
|
||||
// switch (dto.type) {
|
||||
// case 'login':
|
||||
// switch (dto.targetType) {
|
||||
// case 'phone':
|
||||
// return this.verificationService.sendPhoneCode(dto.phone, dto.type);
|
||||
// case 'email':
|
||||
// return this.verificationService.sendEmailCode(dto.email, dto.type);
|
||||
// default:
|
||||
// throw new BadRequestException('不支持的目标类型');
|
||||
// }
|
||||
// default:
|
||||
// throw new BadRequestException('不支持的验证码类型');
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: localhost:5000/backend:latest
|
||||
ports:
|
||||
- containerPort: 3001
|
||||
env:
|
||||
- name: DATABASE_HOST
|
||||
value: "postgres-service"
|
||||
- name: DATABASE_PORT
|
||||
value: "5432"
|
||||
- name: DATABASE_NAME
|
||||
value: "tone_page"
|
||||
- name: DATABASE_USERNAME
|
||||
value: "tone_page"
|
||||
- name: DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: DATABASE_PASSWORD
|
||||
- name: ALIYUN_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: ALIYUN_ACCESS_KEY_ID
|
||||
- name: ALIYUN_ACCESS_KEY_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: ALIYUN_ACCESS_KEY_SECRET
|
||||
- name: ALIYUN_OSS_STS_ROLE_ARN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: ALIYUN_OSS_STS_ROLE_ARN
|
||||
- name: WEBAUTHN_RP_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: WEBAUTHN_RP_ID
|
||||
- name: WEBAUTHN_ORIGIN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: WEBAUTHN_ORIGIN
|
||||
- name: WEBAUTHN_RP_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: backend-secret
|
||||
key: WEBAUTHN_RP_NAME
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend-service
|
||||
spec:
|
||||
selector:
|
||||
app: backend
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3001
|
||||
targetPort: 3001
|
||||
@@ -1,28 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: backend-migration
|
||||
spec:
|
||||
backoffLimit: 0 # 失败不自动重试(防止重复执行)
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: migration
|
||||
image: 192.168.0.200:5000/backend:IMAGE_TAG
|
||||
imagePullPolicy: Always
|
||||
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Running database migrations..."
|
||||
node ./node_modules/typeorm/cli.js migration:run \
|
||||
-d dist/data-source.js
|
||||
|
||||
envFrom:
|
||||
# 和 backend Deployment 用同一套
|
||||
- secretRef:
|
||||
name: backend-secret
|
||||
- secretRef:
|
||||
name: postgres-secret
|
||||
@@ -1,13 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: backend-secret
|
||||
type: Opaque
|
||||
# stringData:
|
||||
# DATABASE_PASSWORD:
|
||||
# ALIYUN_ACCESS_KEY_ID:
|
||||
# ALIYUN_ACCESS_KEY_SECRET:
|
||||
# ALIYUN_OSS_STS_ROLE_ARN:
|
||||
# WEBAUTHN_RP_ID:
|
||||
# WEBAUTHN_ORIGIN:
|
||||
# WEBAUTHN_RP_NAME:
|
||||
@@ -1,32 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: localhost:5000/frontend:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend-service
|
||||
spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
nodePort: 30000
|
||||
selector:
|
||||
app: frontend
|
||||
@@ -1,42 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: postgres-secret
|
||||
volumeMounts:
|
||||
- name: postgres-storage
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- name: postgres-storage
|
||||
hostPath:
|
||||
path: /var/lib/postgres-data
|
||||
type: DirectoryOrCreate
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres-service
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
@@ -1,12 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres-nodeport
|
||||
spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
nodePort: 30001
|
||||
selector:
|
||||
app: postgres
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: postgres-secret
|
||||
type: Opaque
|
||||
# stringData:
|
||||
# POSTGRES_USER:
|
||||
# POSTGRES_PASSWORD:
|
||||
# POSTGRES_DB:
|
||||
@@ -1,33 +0,0 @@
|
||||
# 安装依赖
|
||||
FROM node:22-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 编译
|
||||
FROM node:22-alpine AS builder
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ARG API_BASE
|
||||
ENV API_BASE=$API_BASE
|
||||
RUN pnpm run build
|
||||
|
||||
# 运行
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ARG API_BASE
|
||||
ENV API_BASE=$API_BASE
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import { PhotoProvider, PhotoView } from 'react-photo-view';
|
||||
import 'react-photo-view/dist/react-photo-view.css';
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import Image from "next/image";
|
||||
|
||||
export function BlogContent({ content }: { content?: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||
components={{
|
||||
h1: ({ ...props }) => <h2 className="text-3xl font-bold py-2" {...props} />,
|
||||
h2: ({ ...props }) => <h3 className="text-2xl font-bold py-1" {...props} />,
|
||||
h3: ({ ...props }) => <h4 className="text-xl font-bold py-0.5" {...props} />,
|
||||
h4: ({ ...props }) => <h5 className="text-lg font-bold" {...props} />,
|
||||
h5: ({ ...props }) => <h6 className="text-md font-bold" {...props} />,
|
||||
p: ({ ...props }) => <p className="py-1 text-zinc-700 dark:text-zinc-300" {...props} />,
|
||||
img: ({ src }) => (
|
||||
<PhotoProvider className="w-full">
|
||||
<PhotoView src={src as string}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Image src={src as string} width={0} height={0} style={{ width: '100%', height: 'auto' }} unoptimized alt="加载失败" />
|
||||
</div>
|
||||
</PhotoView>
|
||||
</PhotoProvider>
|
||||
),
|
||||
th: ({ ...props }) => <th className="text-ellipsis text-nowrap border border-zinc-300 dark:border-zinc-500 p-2" {...props} />,
|
||||
td: ({ ...props }) => <td className="border border-zinc-300 dark:border-zinc-500 p-1" {...props} />,
|
||||
table: ({ ...props }) => <div className="overflow-x-auto"><table {...props} /></div>,
|
||||
pre: ({ ...props }) => <pre className="rounded-sm overflow-hidden shadow" {...props} />,
|
||||
blockquote: ({ ...props }) => <blockquote className="pl-3 border-l-5" {...props} />,
|
||||
a: ({ ...props }) => <a className="hover:underline" {...props} />,
|
||||
}}
|
||||
>{content}</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { BlogContent } from "./BlogContent";
|
||||
import { BlogAPI } from "@/lib/api/server";
|
||||
import { handleAPIError } from "@/lib/api/common";
|
||||
import { BlogComments } from "./components/BlogComments";
|
||||
|
||||
interface PageRouteProps {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[] | undefined;
|
||||
} | undefined>
|
||||
}
|
||||
|
||||
async function parseBlogParams({ params: paramsPromise, searchParams: searchParamsPromise }: PageRouteProps) {
|
||||
const params = await paramsPromise ?? {};
|
||||
const searchParams = await searchParamsPromise ?? {};
|
||||
|
||||
if (Array.isArray(searchParams.p)) {
|
||||
return {
|
||||
errorMsg: '密码错误或文章不存在'
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof params.id !== 'string' || params.id.trim() === '') {
|
||||
return {
|
||||
errorMsg: '文章不存在或无权限访问'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
p: searchParams.p,
|
||||
}
|
||||
}
|
||||
|
||||
async function getBlog(paramsResult: ReturnType<typeof parseBlogParams>) {
|
||||
const { errorMsg, id, p } = await paramsResult;
|
||||
if (errorMsg) {
|
||||
return {
|
||||
errorMsg,
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const data = await BlogAPI.getBlogBySlug(`${id}`, p);
|
||||
return {
|
||||
data,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
errorMsg: handleAPIError(error, ({ message }) => message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params, searchParams }: PageRouteProps) {
|
||||
const { errorMsg, data } = await getBlog(parseBlogParams({ params, searchParams }));
|
||||
if (data) {
|
||||
return {
|
||||
title: `${data.title} - 特恩的日志`,
|
||||
description: `${data.description}`
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: `${errorMsg || '错误'} - 特恩的日志`,
|
||||
description: `出错啦`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page({ params, searchParams }: PageRouteProps) {
|
||||
const res = await parseBlogParams({ params, searchParams });
|
||||
const { id, p } = res;
|
||||
let { errorMsg } = res;
|
||||
|
||||
const data = errorMsg ? null
|
||||
: await BlogAPI.getBlogBySlug(`${id}`, p).catch(e => handleAPIError(e, ({ message }) => { errorMsg = message; return null }));
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<div className="max-w-200 mx-auto px-5 overflow-x-hidden mb-10">
|
||||
{errorMsg && <div className="my-20 text-center text-zinc-600 dark:text-zinc-400">{errorMsg}</div>}
|
||||
{data && (
|
||||
<article className="w-full">
|
||||
<header className="flex flex-col items-center">
|
||||
<h1 className="text-center text-2xl sm:text-3xl font-bold mt-10 transition-all duration-500">{data.title}</h1>
|
||||
<time className="text-sm text-zinc-500 dark:text-zinc-300 text-center my-2 sm:my-5 mb-5 transition-all duration-500">发布于:{new Date(data.createdAt).toLocaleString()}</time>
|
||||
</header>
|
||||
<BlogContent content={data.content} />
|
||||
</article>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="border my-5"></div>
|
||||
<BlogComments blogId={data.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { BlogAPI } from "@/lib/api/server";
|
||||
import { handleAPIError } from "@/lib/api/common";
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1_000_000) {
|
||||
return (num / 1_000_000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1_000) {
|
||||
return (num / 1_000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const getBlogDetailUrl = (slug: string): string => {
|
||||
return `/blog/${slug}`;
|
||||
};
|
||||
|
||||
export const metadata = {
|
||||
title: '日志 - 特恩的日志',
|
||||
description: '我随便发点,你也随便看看~',
|
||||
};
|
||||
|
||||
export default async function Blog() {
|
||||
let errorMsg = '';
|
||||
const blogs = await BlogAPI.list().catch(e => {
|
||||
handleAPIError(e, ({ message }) => { errorMsg = message });
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="max-w-120 w-auto mx-auto my-10 flex flex-col gap-8">
|
||||
{
|
||||
errorMsg && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>出错啦</AlertTitle>
|
||||
<AlertDescription>
|
||||
{errorMsg}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
{
|
||||
blogs && blogs.map((blog) => (
|
||||
<article className="w-full px-5 cursor-default" key={blog.id}>
|
||||
<h2 className="text-2xl font-medium">
|
||||
<a
|
||||
className="hover:underline focus:outline-none focus:ring-2 focus:ring-zinc-400 rounded"
|
||||
href={getBlogDetailUrl(blog.slug)}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{blog.title}
|
||||
</a>
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">{blog.description}</p>
|
||||
<footer className="mt-3 text-sm text-zinc-500 flex items-center gap-2">
|
||||
<time dateTime={blog.createdAt}>
|
||||
{new Date(blog.createdAt).toLocaleString('zh-CN')}
|
||||
</time>
|
||||
<span>·</span>
|
||||
<span>{formatNumber(blog.viewCount)} 次访问</span>
|
||||
</footer>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<section className="w-full flex-1 flex flex-col items-center justify-center">
|
||||
<figure className="flex flex-col items-center">
|
||||
<Image
|
||||
src="/avatar.png"
|
||||
alt="TONE 的个人头像"
|
||||
width={180}
|
||||
height={180}
|
||||
className="rounded-full duration-400 size-35 md:size-45 select-none"
|
||||
priority
|
||||
/>
|
||||
</figure>
|
||||
<h1 className='text-4xl md:text-5xl font-bold mt-5 md:mt-8 gradient-title duration-400 select-none'>特恩(TONE)</h1>
|
||||
<p className='text-lg sm:text-xl md:text-2xl mt-3 font-medium text-zinc-400 dark:text-zinc-200 duration-400 select-none'>一名在各个领域反复横跳的程序员</p>
|
||||
<nav className='flex sm:flex-row flex-col gap-2 sm:gap-10 mt-5 md:mt-8 duration-400' aria-label="社交媒体链接">
|
||||
<a href='https://space.bilibili.com/474156211'
|
||||
target='_black'
|
||||
rel="noopener noreferrer"
|
||||
className='bg-[#488fe9] hover:bg-[#3972ba] text-center text-white w-45 sm:w-32 px-6 py-2 text-lg rounded-full cursor-pointer'
|
||||
>
|
||||
哔哩哔哩
|
||||
</a>
|
||||
<a href='https://github.com/tonecn'
|
||||
target='_black'
|
||||
rel="noopener noreferrer"
|
||||
className='bg-[#da843f] hover:bg-[#c87d3e] text-center text-white w-45 sm:w-32 px-6 py-2 text-lg rounded-full cursor-pointer'
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
'use client';
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface ResourceCardImage {
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export default function ResourceCardImage({ imageUrl }: ResourceCardImage) {
|
||||
const [imageError, setImageError] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!imageError && <Image
|
||||
src={imageUrl}
|
||||
alt="资源图片"
|
||||
width={90}
|
||||
height={90}
|
||||
className="rounded-md shadow w-22.5 h-22.5"
|
||||
priority
|
||||
quality={75}
|
||||
onError={() => setImageError(true)}
|
||||
/>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useUserStore } from '@/store/useUserStore';
|
||||
import { User } from '@/lib/types/user';
|
||||
|
||||
export function ClientProvider({
|
||||
initialUser,
|
||||
children,
|
||||
}: {
|
||||
initialUser: User | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const setInitialized = useUserStore((state) => state.setInitialized);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialUser) {
|
||||
setUser(initialUser);
|
||||
}
|
||||
setInitialized();
|
||||
}, [initialUser, setUser, setInitialized]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
CloudUpload,
|
||||
Inbox,
|
||||
LucideIcon,
|
||||
Mail,
|
||||
Server,
|
||||
SquareTerminal,
|
||||
Undo2,
|
||||
UserPen,
|
||||
UsersRound,
|
||||
} from "lucide-react"
|
||||
|
||||
import { NavMain } from "@/app/console/(with-menu)/components/nav-main"
|
||||
import { NavUser } from "@/app/console/(with-menu)/components/nav-user"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
import Link from "next/link"
|
||||
import { User } from "@/lib/types/user"
|
||||
import { Role } from "@/lib/types/role"
|
||||
|
||||
export function AppSidebar({ user, ...props }: React.ComponentProps<typeof Sidebar> & { user: User | null }) {
|
||||
const data = {
|
||||
navMain: null as null | {
|
||||
title: string
|
||||
url: string
|
||||
icon?: LucideIcon
|
||||
isActive?: boolean
|
||||
isHidden?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
isHidden?: boolean
|
||||
}[]
|
||||
}[],
|
||||
}
|
||||
|
||||
data.navMain = [
|
||||
{
|
||||
title: "网站管理",
|
||||
url: "/console/web",
|
||||
icon: SquareTerminal,
|
||||
isHidden: !user?.roles.includes(Role.Admin),
|
||||
items: [
|
||||
{
|
||||
title: "资源",
|
||||
url: "/console/web/resource",
|
||||
},
|
||||
{
|
||||
title: "博客",
|
||||
url: "/console/web/blog",
|
||||
},
|
||||
{
|
||||
title: "配置",
|
||||
url: "/console/web/config",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "用户管理",
|
||||
url: "/console/user/list",
|
||||
icon: UsersRound,
|
||||
isHidden: !user?.roles.includes(Role.Admin),
|
||||
},
|
||||
{
|
||||
title: "邮件系统",
|
||||
url: "/console/mail",
|
||||
icon: Mail,
|
||||
items: [
|
||||
{
|
||||
title: "收件箱",
|
||||
url: "/console/mail/inbox",
|
||||
},
|
||||
{
|
||||
title: "已发送",
|
||||
url: "/console/mail/sent",
|
||||
},
|
||||
{
|
||||
title: "发送邮件",
|
||||
url: "/console/mail/send",
|
||||
},
|
||||
{
|
||||
title: "邮件管理",
|
||||
url: "/console/mail/manage",
|
||||
isHidden: !user?.roles.includes(Role.Admin),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "文件存储",
|
||||
url: "/console/storage",
|
||||
icon: CloudUpload,
|
||||
},
|
||||
{
|
||||
title: "虚拟云空间",
|
||||
url: "/console/vspace",
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: "虚拟主机",
|
||||
url: "/console/vserver",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
title: "账户信息",
|
||||
url: "/console/profile",
|
||||
icon: UserPen
|
||||
},
|
||||
{
|
||||
title: "前往首页",
|
||||
url: "/",
|
||||
icon: Undo2,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<Link href="/console">
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<div className="cursor-pointer">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<SquareTerminal className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-semibold">特恩的日志 - 控制台</span>
|
||||
<span className="">v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={user} />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet } from "@/components/ui/field";
|
||||
import { useUserStore } from "@/store/useUserStore";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { ReactElement, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { AuthAPI } from "@/lib/api/client";
|
||||
import { GeneralErrorHandler, handleAPIError } from "@/lib/api/common";
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function Page() {
|
||||
const userStore = useUserStore();
|
||||
const user = userStore.user;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}>
|
||||
<FieldGroup className="gap-5">
|
||||
<FieldSet>
|
||||
<FieldLegend>账户基础信息</FieldLegend>
|
||||
<FieldDescription>这是当前账户的基础信息</FieldDescription>
|
||||
<FieldGroup className="gap-5">
|
||||
{
|
||||
[
|
||||
{
|
||||
name: 'username',
|
||||
localName: '用户名',
|
||||
required: true,
|
||||
defaultValue: user?.username,
|
||||
},
|
||||
{
|
||||
name: 'nickname',
|
||||
localName: '昵称',
|
||||
required: true,
|
||||
defaultValue: user?.nickname,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
localName: '电子邮箱',
|
||||
required: false,
|
||||
defaultValue: user?.email,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
localName: '手机号',
|
||||
required: false,
|
||||
defaultValue: user?.phone,
|
||||
description: '当前仅支持中国大陆(+86)手机号'
|
||||
},
|
||||
].map(({ name, localName, required, defaultValue, description }) => (
|
||||
<Field key={name} className="gap-2">
|
||||
<FieldLabel htmlFor={`console-profile-${name}`} className="text-zinc-800">
|
||||
{localName}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`console-profile-${name}`}
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={localName}
|
||||
required={required}
|
||||
disabled
|
||||
/>
|
||||
{
|
||||
description && (
|
||||
<FieldDescription>
|
||||
{description}
|
||||
</FieldDescription>
|
||||
)
|
||||
}
|
||||
</Field>
|
||||
))
|
||||
}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
<Field orientation="horizontal">
|
||||
<Button type="submit" disabled>保存</Button>
|
||||
<Button variant="outline" type="button" disabled>
|
||||
编辑
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<FieldSeparator className="mt-2 mb-2" />
|
||||
<FieldGroup className="gap-5">
|
||||
<FieldSet>
|
||||
<FieldLegend>通行证列表</FieldLegend>
|
||||
<FieldDescription>
|
||||
通行证(PassKey),一种先进的无密码身份验证技术。
|
||||
</FieldDescription>
|
||||
<PasskeyList />
|
||||
<div>
|
||||
<AddPasskeyDialog>
|
||||
<Button>添加通行证</Button>
|
||||
</AddPasskeyDialog>
|
||||
</div>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface AddPasskeyDialogProps {
|
||||
children: ReactElement;
|
||||
}
|
||||
function AddPasskeyDialog({ children }: AddPasskeyDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSubmit = async (name: string) => {
|
||||
try {
|
||||
name = name.trim();
|
||||
if (name.length === 0) {
|
||||
throw new Error('通行证名称不能为空')
|
||||
}
|
||||
|
||||
const options = await AuthAPI.getPasskeyRegisterOptions();
|
||||
|
||||
const credential = await startRegistration({ optionsJSON: options }).catch(() => null);
|
||||
|
||||
if (credential === null) {
|
||||
throw new Error('认证超时');
|
||||
}
|
||||
|
||||
const registerRes = await AuthAPI.passkeyRegister(name, credential);
|
||||
if (registerRes.id) {
|
||||
toast.success('添加成功');
|
||||
setOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
handleAPIError(error, GeneralErrorHandler);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加通行证</DialogTitle>
|
||||
<DialogDescription>
|
||||
如果能添加成功,则说明您的设备支持;如果添加失败了,说明您的设备不支持😊
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
handleSubmit(formData.get('name')?.toString() || '');
|
||||
}}>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="console-add-passkey-name">通行证名称</Label>
|
||||
<Input id="console-add-passkey-name" name="name" required />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">下一步</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
)
|
||||
}
|
||||
|
||||
function PasskeyList() {
|
||||
return (
|
||||
<Table>
|
||||
{/* <TableCaption>A list of your recent invoices.</TableCaption> */}
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">INV001</TableCell>
|
||||
<TableCell>Paid</TableCell>
|
||||
<TableCell>Credit Card</TableCell>
|
||||
<TableCell className="text-right">$250.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BlogPermissionCheckBoxs } from "./BlogPermissionCheckBoxs";
|
||||
import { AdminAPI } from "@/lib/api/client";
|
||||
import { copyShareURL } from "./utils";
|
||||
|
||||
interface AddBlogProps {
|
||||
children: React.ReactNode;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function AddBlog({ children, onRefresh }: AddBlogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [blog, setBlog] = useState({
|
||||
title: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
contentUrl: "",
|
||||
permissions: [] as BlogPermission[],
|
||||
password: "",
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const res = await AdminAPI.createBlog({
|
||||
...blog,
|
||||
});
|
||||
if (res) {
|
||||
setOpen(false);
|
||||
onRefresh();
|
||||
toast.success("添加成功");
|
||||
setBlog({
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
contentUrl: '',
|
||||
permissions: [],
|
||||
password: '',
|
||||
})
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "添加失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加博客</DialogTitle>
|
||||
<DialogDescription>
|
||||
保存前请确认博客信息填写正确、权限配置合理
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="title" className="text-right">
|
||||
标题
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
className="col-span-3"
|
||||
value={blog.title}
|
||||
onChange={(e) => setBlog({ ...blog, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
描述
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
className="col-span-3"
|
||||
value={blog.description}
|
||||
onChange={(e) => setBlog({ ...blog, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="slug" className="text-right">
|
||||
Slug
|
||||
</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
className="col-span-3"
|
||||
value={blog.slug}
|
||||
onChange={(e) => setBlog({ ...blog, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="contentUrl" className="text-right">
|
||||
文章URL
|
||||
</Label>
|
||||
<Input
|
||||
id="contentUrl"
|
||||
className="col-span-3"
|
||||
value={blog.contentUrl}
|
||||
onChange={(e) => setBlog({ ...blog, contentUrl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">
|
||||
文章权限
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
{
|
||||
<BlogPermissionCheckBoxs
|
||||
permissions={blog.permissions}
|
||||
onCheckedChange={(p, n) => setBlog({
|
||||
...blog,
|
||||
permissions: n ?
|
||||
[...blog.permissions, p] :
|
||||
[...blog.permissions].filter(p => p !== p),
|
||||
})}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
blog.permissions.includes(BlogPermission.ByPassword) &&
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="password" className="text-right">
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
className="col-span-3"
|
||||
value={blog.password}
|
||||
onChange={(e) => setBlog({ ...blog, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<DialogFooter >
|
||||
<div className="flex justify-between w-full">
|
||||
<Button type="button" variant='outline' onClick={() => copyShareURL({
|
||||
slug: blog.slug,
|
||||
password: blog.password,
|
||||
permissions: blog.permissions,
|
||||
})}>复制分享链接</Button>
|
||||
<div>
|
||||
<Button type="button" variant='secondary' onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button type="button" onClick={handleSubmit}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
|
||||
|
||||
const blogPermissions = [
|
||||
{
|
||||
permission: BlogPermission.Public,
|
||||
localText: '公开可读',
|
||||
},
|
||||
{
|
||||
permission: BlogPermission.ByPassword,
|
||||
localText: '受密码保护',
|
||||
},
|
||||
{
|
||||
permission: BlogPermission.List,
|
||||
localText: '显示在列表中',
|
||||
},
|
||||
{
|
||||
permission: BlogPermission.AllowComments,
|
||||
localText: '允许评论',
|
||||
}
|
||||
] as const;
|
||||
|
||||
interface BlogPermissionCheckBoxsProps {
|
||||
permissions: BlogPermission[];
|
||||
onCheckedChange: (permission: BlogPermission, newState: boolean) => void;
|
||||
}
|
||||
|
||||
export function BlogPermissionCheckBoxs({ permissions, onCheckedChange }: BlogPermissionCheckBoxsProps) {
|
||||
return (
|
||||
<div className="flex gap-3 gap-x-8 flex-wrap">
|
||||
{
|
||||
blogPermissions.map((v, i) => (
|
||||
<div key={`blog-permission-option-${i}`} className="flex gap-2">
|
||||
<Checkbox
|
||||
id={`blog-permission-option-checkbox-${i}`}
|
||||
checked={permissions.includes(v.permission)}
|
||||
onCheckedChange={newChecked => onCheckedChange(v.permission, !!newChecked)} />
|
||||
<Label htmlFor={`blog-permission-option-checkbox-${i}`} className="whitespace-nowrap">{v.localText}</Label>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { AdminAPI } from "@/lib/api/client";
|
||||
import { base62 } from "@/lib/utils";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SetPasswordDialogProps {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SetPasswordDialog({ id, children }: SetPasswordDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password) {
|
||||
return toast.error('请输入密码');
|
||||
}
|
||||
|
||||
await AdminAPI.setBlogPassword(id, password).then(() => {
|
||||
toast.success('修改成功');
|
||||
setOpen(false);
|
||||
}).catch(e => {
|
||||
toast.error(`${e.message || e || '请求失败'}`)
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPassword('');
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleCopyShareURL = () => {
|
||||
if (!password) {
|
||||
return toast.warning('请先填写新密码');
|
||||
}
|
||||
const url = `${window.location.origin}/blog/${base62.encode(Buffer.from(id.replace(/-/g, ''), 'hex'))}?p=${password}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
toast.success('分享链接复制成功,请点击保存按钮以提交新密码');
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => setOpen(v)}>
|
||||
<form>
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改密码</DialogTitle>
|
||||
<DialogDescription>
|
||||
通过密码访问受保护的文章,需开启“受密码保护”权限。注意复制URL需要填写完新密码后再点击
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="new-password">新密码</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="w-full flex justify-between">
|
||||
<div>
|
||||
<Button variant='secondary' onClick={handleCopyShareURL}>复制URl</Button>
|
||||
</div>
|
||||
<div className="flex gap-5">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" onClick={handleSubmit}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function copyShareURL(data: {
|
||||
slug: string;
|
||||
password: string;
|
||||
permissions: BlogPermission[];
|
||||
}) {
|
||||
const slug = data.slug.trim();
|
||||
const password = data.password.trim();
|
||||
const permissions = data.permissions;
|
||||
|
||||
if (slug.length === 0) {
|
||||
return toast.warning('请先填写Slug')
|
||||
}
|
||||
|
||||
let url = `${window.location.origin}/blog/${slug}`;
|
||||
|
||||
if (permissions.includes(BlogPermission.ByPassword)) {
|
||||
if (password.length === 0) {
|
||||
return toast.warning('开启了密码保护,但无法获取有效的密码,无法生成有效URL')
|
||||
} else {
|
||||
url += `?p=${password}`;
|
||||
}
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toast.success('复制成功');
|
||||
}, () => {
|
||||
toast.error('复制失败,请手动复制');
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>config</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// import { Button } from "@/components/ui/button";
|
||||
// import { Input } from "@/components/ui/input";
|
||||
// import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
// import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
// import { useState, useCallback } from "react";
|
||||
// import { toast } from "sonner";
|
||||
// import LoginHeader from "./LoginHeader";
|
||||
// import { SendCodeFormData } from "./types";
|
||||
// import { Label } from "@/components/ui/label";
|
||||
|
||||
// export default function EmailLoginMode({ onSendCode }: { onSendCode: (data: SendCodeFormData) => Promise<boolean> }) {
|
||||
// const [email, setEmail] = useState("");
|
||||
// const handleSendCode = useCallback(() => {
|
||||
// if (!email.trim().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||||
// toast.error('请输入正确的邮箱地址');
|
||||
// return;
|
||||
// }
|
||||
// onSendCode({
|
||||
// type: 'email',
|
||||
// email,
|
||||
// })
|
||||
// }, [email, onSendCode]);
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// <LoginHeader />
|
||||
// <div className="grid gap-3">
|
||||
// <Label htmlFor="email">电子邮箱</Label>
|
||||
// <Input
|
||||
// id="email-login-mode-email"
|
||||
// name="email"
|
||||
// type="text"
|
||||
// placeholder="电子邮箱"
|
||||
// value={email}
|
||||
// onChange={(e) => setEmail(e.target.value)}
|
||||
// required />
|
||||
// </div>
|
||||
// <div className="grid gap-3">
|
||||
// <div className="flex items-center h-4">
|
||||
// <Label htmlFor="code">验证码</Label>
|
||||
// </div>
|
||||
// <div className="flex gap-5">
|
||||
// <InputOTP
|
||||
// id="email-login-mode-code"
|
||||
// name="code"
|
||||
// maxLength={6}
|
||||
// pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
// required
|
||||
// >
|
||||
// <InputOTPGroup>
|
||||
// <InputOTPSlot index={0} />
|
||||
// <InputOTPSlot index={1} />
|
||||
// <InputOTPSlot index={2} />
|
||||
// <InputOTPSlot index={3} />
|
||||
// <InputOTPSlot index={4} />
|
||||
// <InputOTPSlot index={5} />
|
||||
// </InputOTPGroup>
|
||||
// </InputOTP>
|
||||
// <Button type="button" variant="secondary" onClick={handleSendCode}>获取验证码</Button>
|
||||
// </div>
|
||||
// </div>
|
||||
// <Button type="submit" className="w-full">
|
||||
// 注册并登录
|
||||
// </Button>
|
||||
// </>
|
||||
// )
|
||||
// }
|
||||
@@ -1,17 +0,0 @@
|
||||
interface LoginHeaderProps {
|
||||
h1?: string;
|
||||
h2?: string;
|
||||
}
|
||||
|
||||
export default function LoginHeader({ h1, h2 }: LoginHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">{h1 || '欢迎回来'}</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
{h2 || '登陆到您的账户'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { AuthAPI } from "@/lib/api/client";
|
||||
import LoginHeader from "./LoginHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
|
||||
export default function PasskeyLoginPage() {
|
||||
return (
|
||||
<>
|
||||
<LoginHeader h2="使用通行证登录到您的账户" />
|
||||
<div className="h-37.5 flex justify-center items-center">
|
||||
<span className="text-sm text-zinc-500 border rounded-2xl p-5">还没想好这里放点啥捏~</span>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
登录
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export async function handleSubmit() {
|
||||
const optionsJSON = await AuthAPI.getLoginByPasskeyOptions();
|
||||
const credentialResponse = await startAuthentication({ optionsJSON });
|
||||
return AuthAPI.loginByPasskey(credentialResponse);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import LoginHeader from "./LoginHeader";
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { HumanVerification } from "@/components/human-verification";
|
||||
import { AuthAPI, SmsAPI } from "@/lib/api/client";
|
||||
import { handleAPIError } from "@/lib/api/common";
|
||||
|
||||
export default function SmsLoginMode() {
|
||||
const [phone, setPhone] = useState("");
|
||||
const handleSendCode = useCallback(async () => {
|
||||
await SmsAPI.sendLoginSms(phone)
|
||||
.then(() => toast.success('验证码已发送!'))
|
||||
.catch(e => handleAPIError(e, ({ message }) => toast.error(`${message}`)))
|
||||
}, [phone]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader />
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone-login-mode-phone"
|
||||
name="phone"
|
||||
type="text"
|
||||
placeholder="+86 手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center h-4">
|
||||
<Label htmlFor="code">验证码</Label>
|
||||
</div>
|
||||
<div className="flex gap-1 overflow-hidden items-center flex-row-reverse">
|
||||
<HumanVerification onSuccess={handleSendCode} >
|
||||
<Button type="button" variant="secondary" disabled>
|
||||
获取验证码
|
||||
</Button>
|
||||
</HumanVerification>
|
||||
<div className="flex-1 min-w-0">
|
||||
<InputOTP
|
||||
id="phone-login-mode-code"
|
||||
name="code"
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
required
|
||||
>
|
||||
<InputOTPGroup className="w-full flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<InputOTPSlot
|
||||
key={i}
|
||||
index={i}
|
||||
className="flex-1 aspect-square"
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled>
|
||||
控制台还在施工,暂不开放注册功能噢~
|
||||
</Button>
|
||||
<div className="hidden" aria-hidden>
|
||||
oi! 当你看到这里,如果有兴趣可以试试绕过前端校验进行注册,后端接口没封,不过登陆之后也确实没啥好玩的
|
||||
玩的开心,如果发现重大漏洞,请手下留情喔~网站右下角有我的邮箱,告之后将以“哈基米(BNB)”答谢~
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export async function handleSubmit(formData: FormData) {
|
||||
const phone = formData.get('phone')?.toString() || '';
|
||||
const code = formData.get('code')?.toString() || '';
|
||||
|
||||
return AuthAPI.loginBySms(phone, code)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "../components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { UserAPI } from "@/lib/api/server";
|
||||
import { ClientProvider } from "./ClientProvider";
|
||||
import { Metadata } from "next";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "特恩的日志",
|
||||
description: "一名在各个领域反复横跳的程序员",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const user = await UserAPI.me().catch(() => null);
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ClientProvider initialUser={user}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</ClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/console',
|
||||
},
|
||||
sitemap: 'https://www.tonesc.cn/sitemap.xml',
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { BlogAPI } from '@/lib/api/server'
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// 获取所有博客
|
||||
const blogs = await BlogAPI.list().catch(() => [])
|
||||
|
||||
const blogUrls = blogs.map(blog => {
|
||||
return {
|
||||
url: `https://www.tonesc.cn/blog/${blog.slug}`,
|
||||
lastModified: new Date(blog.updatedAt),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}
|
||||
})
|
||||
|
||||
// 静态页面
|
||||
const staticUrls = [
|
||||
{
|
||||
url: 'https://www.tonesc.cn/',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly' as const,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: 'https://www.tonesc.cn/blog',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://www.tonesc.cn/resource',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
},
|
||||
]
|
||||
|
||||
return [...staticUrls, ...blogUrls]
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Mail } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
const EMAIL = "tonesc.cn@gmail.com";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-zinc-300 dark:border-zinc-500">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-950 px-4 py-3 md:py-5 sm:px-10 md:px-20 flex flex-col sm:flex-row justify-between items-center gap-4 transition-all">
|
||||
{/* 版权与备案信息 */}
|
||||
<div className="text-center sm:text-left">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-zinc-500 dark:text-zinc-300 hover:text-zinc-700 dark:hover:text-zinc-100 hover:underline focus:outline-none focus:underline"
|
||||
>
|
||||
备案号:渝ICP备2023009516号-1
|
||||
</a>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-300">
|
||||
© {new Date().getFullYear()} TONE Page. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 联系方式弹出框 */}
|
||||
<address className="not-italic">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' size='sm' >
|
||||
<Mail className="text-zinc-600 dark:text-zinc-300" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit">
|
||||
<a
|
||||
href={`mailto:${EMAIL}`}
|
||||
className="text-sm text-zinc-800 dark:text-zinc-200 hover:underline focus:outline-none focus:underline"
|
||||
>
|
||||
{EMAIL}
|
||||
</a>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</address>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { useUserStore } from "@/store/useUserStore";
|
||||
|
||||
|
||||
export default function Header() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const userStore = useUserStore();
|
||||
|
||||
const menuItems = [
|
||||
{ name: '特恩(TONE)', path: '/' },
|
||||
{ name: '资源', path: '/resource' },
|
||||
{ name: '博客', path: '/blog' },
|
||||
{ name: '控制台', path: '/console' },
|
||||
];
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, path: string) => {
|
||||
if (path === '/console') {
|
||||
e.preventDefault();
|
||||
router.push(userStore.user ? '/console' : '/console/login');
|
||||
setShowMenu(false);
|
||||
} else {
|
||||
setShowMenu(false);
|
||||
}
|
||||
}
|
||||
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!showMenu && menuButtonRef.current) {
|
||||
menuButtonRef.current.focus();
|
||||
}
|
||||
}, [showMenu]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 backdrop-blur-sm bg-white/40 dark:bg-black/40 shadow dark:shadow-zinc-500" role="banner" aria-label="网站顶部导航栏">
|
||||
<div className="flex items-center justify-between px-10 md:h-18 md:px-20 h-14 duration-300" aria-label="主菜单">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"cursor-pointer font-medium text-zinc-500 dark:text-zinc-300 hover:text-zinc-800 dark:hover:text-zinc-100 border-b-4 border-transparent duration-200",
|
||||
pathname === "/" && "text-zinc-800 dark:text-zinc-100"
|
||||
)}
|
||||
aria-current={pathname === "/" ? "page" : undefined}
|
||||
>
|
||||
<span className="sr-only">特恩(TONE)</span>
|
||||
{pathname === "/"
|
||||
? <span className="text-2xl" aria-hidden="true" >🍭</span>
|
||||
: <span className="md:text-lg" aria-hidden="true">特恩(TONE)</span>}
|
||||
</Link>
|
||||
|
||||
<nav className={cn(
|
||||
"items-center gap-12 hidden sm:flex",
|
||||
)}>
|
||||
{menuItems.slice(1).map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.path}
|
||||
className={cn(
|
||||
"cursor-pointer md:text-lg font-medium text-zinc-500 dark:text-zinc-300 hover:text-zinc-800 dark:hover:text-zinc-100 border-b-4 border-transparent duration-200",
|
||||
pathname.startsWith(item.path) && "text-zinc-800 dark:text-zinc-100 border-b-pink-500"
|
||||
)}
|
||||
onClick={e => handleClick(e, item.path)}
|
||||
aria-current={pathname === item.path ? "page" : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
className="sm:hidden text-zinc-600"
|
||||
onClick={() => setShowMenu(true)}
|
||||
aria-label="打开主菜单"
|
||||
>菜单</button>
|
||||
</div>
|
||||
</header >
|
||||
|
||||
<Drawer direction="right" open={showMenu} onOpenChange={setShowMenu}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="flex justify-between">
|
||||
<span>菜单</span>
|
||||
<button
|
||||
onClick={() => setShowMenu(false)}
|
||||
aria-label="关闭菜单"
|
||||
>
|
||||
<X className="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>请选择需要前往的页面</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<nav className="w-full flex flex-col px-4 gap-2" aria-label="移动设备主菜单">
|
||||
{menuItems.slice(1).map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.path}
|
||||
onClick={e => handleClick(e, item.path)}
|
||||
aria-current={pathname === item.path ? "page" : undefined}
|
||||
>
|
||||
<Button className="w-full" size='lg'
|
||||
variant={pathname.startsWith(item.path) ? 'default' : 'outline'}
|
||||
>{item.name}</Button>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react"
|
||||
|
||||
interface HumanVerificationProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
onFail?: (reason?: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function HumanVerification({ open, onOpenChange, onSuccess, children }: HumanVerificationProps) {
|
||||
const [i_open, i_setOpen] = useState(false);
|
||||
const setOpen = useCallback((o: boolean) => {
|
||||
i_setOpen(o);
|
||||
onOpenChange?.(o);
|
||||
}, [onOpenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (i_open) {
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
}
|
||||
}, [i_open, onSuccess, setOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={open ?? i_open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-80">
|
||||
<DialogHeader>
|
||||
<DialogTitle>人机验证</DialogTitle>
|
||||
<DialogDescription>
|
||||
请拖动滑块以使得图片水平
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// import { UserAPI } from "@/lib/api/client";
|
||||
// import useSWR from "swr";
|
||||
|
||||
// export function useUserMe({ onError }: { onError?: (e: any) => void } = {}) {
|
||||
// const isClientSide = typeof window !== 'undefined';
|
||||
|
||||
// const { data: user, isLoading, error } = useSWR(
|
||||
// '/api/user/me',
|
||||
// async () => {
|
||||
// if (isClientSide && !localStorage.getItem('token')) {
|
||||
// throw Object.assign(new Error('未登录'), { statusCode: -1 });
|
||||
// }
|
||||
// return UserAPI.me();
|
||||
// },
|
||||
// {
|
||||
// onError: (error) => {
|
||||
// if (error.statusCode === 401) {
|
||||
// if (isClientSide) {
|
||||
// localStorage.removeItem('token');
|
||||
// }
|
||||
// }
|
||||
|
||||
// onError?.(error);
|
||||
// },
|
||||
// revalidateIfStale: false,
|
||||
// revalidateOnFocus: false,
|
||||
// shouldRetryOnError: (err) => {
|
||||
// if ([-1, 401].includes(err.statusCode)) {
|
||||
// return false;
|
||||
// }
|
||||
// return true;
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
|
||||
// return {
|
||||
// user,
|
||||
// isLoading,
|
||||
// error
|
||||
// }
|
||||
// }
|
||||
@@ -1,53 +0,0 @@
|
||||
import { APIResponse, HttpMethod, normalizeAPIError } from './common';
|
||||
|
||||
interface ClientFetchRequestOptions extends RequestInit {
|
||||
method?: HttpMethod;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export async function clientFetch<T = unknown>(
|
||||
endpoint: string,
|
||||
options: ClientFetchRequestOptions = {}
|
||||
): Promise<T> {
|
||||
const defaultHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body ?? JSON.stringify(options.body),
|
||||
credentials: 'include',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw JSON.parse(errorText);
|
||||
}
|
||||
|
||||
const data: APIResponse<T> = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw data;
|
||||
}
|
||||
|
||||
return data.data as T;
|
||||
} catch (error) {
|
||||
normalizeAPIError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export * as AuthAPI from './endpoints/auth.client'
|
||||
export * as UserAPI from './endpoints/user.client'
|
||||
export * as SmsAPI from './endpoints/sms.client'
|
||||
export * as AdminAPI from './endpoints/admin.client'
|
||||
export * as OSSAPI from './endpoints/oss.client'
|
||||
export * as BlogAPI from './endpoints/blog.client'
|
||||
@@ -1,73 +0,0 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
success: boolean;
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number = 400,
|
||||
public code: number = -1,
|
||||
public data: unknown = null
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAPIError(error: unknown): never {
|
||||
if (error instanceof APIError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new APIError(
|
||||
error.message || '未知错误',
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const { message, status, code, data } = {
|
||||
message: '未知错误',
|
||||
status: 400,
|
||||
code: -1,
|
||||
data: null,
|
||||
...error
|
||||
};
|
||||
|
||||
throw new APIError(
|
||||
message,
|
||||
status,
|
||||
code,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
throw new APIError((error instanceof Error ? `${error.message}` : '') || '未知错误', 400);
|
||||
}
|
||||
|
||||
export function handleAPIError<T>(error: unknown, handler: (e: APIError) => T): T {
|
||||
if (error instanceof APIError) {
|
||||
return handler(error);
|
||||
}
|
||||
|
||||
try {
|
||||
normalizeAPIError(error)
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
return handler(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function GeneralErrorHandler(e: APIError) {
|
||||
toast.error(`${e.message}`)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { Resource } from "@/lib/types/resource";
|
||||
import { clientFetch } from "../client";
|
||||
import { Blog } from "@/lib/types/blog";
|
||||
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
|
||||
import { Role } from "@/lib/types/role";
|
||||
import { APIError } from "../common";
|
||||
|
||||
export interface UserEntity {
|
||||
userId: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
// ======== Resource ========
|
||||
export async function listResources() {
|
||||
return clientFetch<Resource[]>('/api/admin/web/resource')
|
||||
}
|
||||
|
||||
interface CreateResourceParams {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
link: string;
|
||||
tags: {
|
||||
name: string;
|
||||
type: string;
|
||||
}[];
|
||||
}
|
||||
export async function createResource(data: CreateResourceParams) {
|
||||
data.title = data.title.trim();
|
||||
data.description = data.description.trim();
|
||||
data.imageUrl = data.imageUrl.trim();
|
||||
data.link = data.link.trim();
|
||||
for (const tag of data.tags) {
|
||||
tag.name = tag.name.trim();
|
||||
}
|
||||
|
||||
return clientFetch('/api/admin/web/resource', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getResource(id: string) {
|
||||
return clientFetch<Resource>(`/api/admin/web/resource/${id}`)
|
||||
}
|
||||
|
||||
export async function removeResource(id: string) {
|
||||
return clientFetch<void>(`/api/admin/web/resource/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateResourceParams {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
link: string;
|
||||
tags: {
|
||||
name: string;
|
||||
type: string;
|
||||
}[];
|
||||
}
|
||||
export async function updateResource(id: string, data: UpdateResourceParams) {
|
||||
data.title = data.title.trim();
|
||||
data.description = data.description.trim();
|
||||
data.imageUrl = data.imageUrl.trim();
|
||||
data.link = data.link.trim();
|
||||
for (const tag of data.tags) {
|
||||
tag.name = tag.name.trim();
|
||||
}
|
||||
|
||||
return clientFetch<Resource>(`/api/admin/web/resource/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ======== Blog ========
|
||||
interface CreateBlogParams {
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
contentUrl: string;
|
||||
permissions: BlogPermission[];
|
||||
password: string;
|
||||
}
|
||||
export async function createBlog(data: CreateBlogParams) {
|
||||
data.title = data.title.trim()
|
||||
data.description = data.description.trim()
|
||||
data.slug = data.slug.trim()
|
||||
data.contentUrl = data.contentUrl.trim()
|
||||
data.password = data.password.trim()
|
||||
|
||||
if (data.title.length === 0) {
|
||||
throw new APIError('标题不得为空')
|
||||
}
|
||||
if (data.description.length === 0) {
|
||||
throw new APIError('描述不得为空')
|
||||
}
|
||||
if (data.slug.length === 0) {
|
||||
throw new APIError('Slug不得为空')
|
||||
}
|
||||
if (data.contentUrl.length === 0) {
|
||||
throw new APIError('文章URL不得为空')
|
||||
}
|
||||
|
||||
return clientFetch<Blog>('/api/admin/web/blog', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBlog(id: string) {
|
||||
return clientFetch<Blog>(`/api/admin/web/blog/${id}`)
|
||||
}
|
||||
|
||||
export async function listBlogs() {
|
||||
return clientFetch<Blog[]>('/api/admin/web/blog')
|
||||
}
|
||||
|
||||
export async function removeBlog(id: string) {
|
||||
// ? Blog
|
||||
return clientFetch<Blog>(`/api/admin/web/blog/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
interface UpdateBlogParams {
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
contentUrl: string;
|
||||
permissions: BlogPermission[],
|
||||
}
|
||||
export async function updateBlog(id: string, data: UpdateBlogParams) {
|
||||
data.title = data.title.trim();
|
||||
data.description = data.description.trim();
|
||||
data.slug = data.slug.trim();
|
||||
data.contentUrl = data.contentUrl.trim();
|
||||
|
||||
if (data.title.length === 0) {
|
||||
throw new APIError('标题不得为空')
|
||||
}
|
||||
if (data.description.length === 0) {
|
||||
throw new APIError('描述不得为空')
|
||||
}
|
||||
if (data.slug.length === 0) {
|
||||
throw new APIError('Slug不得为空')
|
||||
}
|
||||
if (data.contentUrl.length === 0) {
|
||||
throw new APIError('文章URL不得为空')
|
||||
}
|
||||
|
||||
return clientFetch<Blog>(`/api/admin/web/blog/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function setBlogPassword(id: string, password: string) {
|
||||
password = password.trim();
|
||||
return clientFetch<boolean>(`/api/admin/web/blog/${id}/password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ======== User ========
|
||||
interface CreateUserParams {
|
||||
username: string | null;
|
||||
nickname: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
password: string | null;
|
||||
}
|
||||
export async function createUser(data: CreateUserParams) {
|
||||
type Keys = keyof CreateUserParams;
|
||||
for (const key in data) {
|
||||
data[key as Keys] = data[key as Keys]?.trim() || null;
|
||||
}
|
||||
|
||||
return clientFetch<UserEntity>("/api/admin/user", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function getUser(id: string) {
|
||||
return clientFetch<UserEntity>(`/api/admin/user/${id}`);
|
||||
}
|
||||
|
||||
export interface UserListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
export interface UserListResponse {
|
||||
items: UserEntity[],
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
export function listUsers(params?: UserListParams): Promise<UserListResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.page) searchParams.set('page', params.page.toString())
|
||||
if (params?.pageSize) searchParams.set('pageSize', params.pageSize.toString())
|
||||
|
||||
return clientFetch<UserListResponse>('/api/admin/user')
|
||||
}
|
||||
|
||||
export async function removeUser(userId: string, soft: boolean = true) {
|
||||
/** 我也不知道后端返回的是个啥...没报错就当成功吧 */
|
||||
return clientFetch<unknown>(`/api/admin/user/${userId}?soft=${soft}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function setUserPassword(userId: string, password: string) {
|
||||
password = password.trim();
|
||||
|
||||
return clientFetch<void>(`/api/admin/user/${userId}/password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export interface updateUser {
|
||||
username: string;
|
||||
nickname: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
export async function updateUser(userId: string, user: updateUser) {
|
||||
user.username = user.username.trim();
|
||||
user.nickname = user.nickname.trim();
|
||||
user.email = user.email?.trim() || null;
|
||||
user.phone = user.phone?.trim() || null;
|
||||
|
||||
return clientFetch<UserEntity>(`/api/admin/user/${userId}`, {
|
||||
body: JSON.stringify(user),
|
||||
method: "PUT",
|
||||
});
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { User } from "@/lib/types/user";
|
||||
import { clientFetch } from "../client";
|
||||
import { APIError } from "../common";
|
||||
import { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON } from "@simplewebauthn/browser";
|
||||
|
||||
export async function loginByPassword(identifier: string, password: string) {
|
||||
identifier = identifier.trim();
|
||||
password = password.trim();
|
||||
if (identifier.length === 0 || password.length === 0) {
|
||||
throw new APIError('请输入账户和密码')
|
||||
}
|
||||
|
||||
if (identifier.length < 1 || identifier.length > 254) {
|
||||
throw new APIError('账户长度只能为1~254位')
|
||||
}
|
||||
|
||||
if (password.length < 6 || password.length > 32) {
|
||||
throw new APIError('密码长度只能为6~32位')
|
||||
}
|
||||
|
||||
return clientFetch<{ user: User }>('/api/auth/login/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
identifier,
|
||||
password,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginBySms(phone: string, code: string) {
|
||||
phone = phone.trim();
|
||||
code = code.trim();
|
||||
if (phone.length === 0 || code.length === 0) {
|
||||
throw new APIError('请输入手机号及短信验证码')
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new APIError('请输入合法的中国大陆手机号');
|
||||
}
|
||||
|
||||
|
||||
if (! /\d{6}/.test(code)) {
|
||||
throw new APIError('密码长度只能为6~32位')
|
||||
}
|
||||
|
||||
return clientFetch<{ user: User }>('/api/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
phone,
|
||||
code,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return clientFetch('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
|
||||
// ======== PassKey ========
|
||||
export async function getPasskeyRegisterOptions() {
|
||||
return clientFetch<PublicKeyCredentialCreationOptionsJSON>('/api/auth/passkey/register/options', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function passkeyRegister(name: string, credentialResponse: RegistrationResponseJSON) {
|
||||
name = name.trim();
|
||||
if (name.length === 0) {
|
||||
throw new APIError('通行证名称不得为空');
|
||||
}
|
||||
|
||||
return clientFetch<{ id: string; name: string; createdAt: string }>('/api/auth/passkey/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
credentialResponse,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLoginByPasskeyOptions() {
|
||||
return clientFetch<PublicKeyCredentialRequestOptionsJSON>('/api/auth/passkey/login/options', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** @lint-ignore */
|
||||
export async function loginByPasskey(credentialResponse: AuthenticationResponseJSON) {
|
||||
return clientFetch<{ user: User }>('/api/auth/passkey/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { BlogComment } from "@/lib/types/blogComment";
|
||||
import { clientFetch } from "../client";
|
||||
|
||||
export async function getBlog(id: string, password?: string) {
|
||||
return clientFetch<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
content: string;
|
||||
}>(`/api/blog/${id}` + (password ? `?p=${password}` : ''));
|
||||
}
|
||||
|
||||
export async function getComments(id: string) {
|
||||
return clientFetch<BlogComment[]>(`/api/blog/${id}/comments`);
|
||||
}
|
||||
|
||||
export async function createComment(blogId: string, content: string, parentId?: string) {
|
||||
return clientFetch<BlogComment>(`/api/blog/${blogId}/comment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
parentId: parentId || null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Blog } from "@/lib/types/blog";
|
||||
import { serverFetch } from "../server";
|
||||
|
||||
export async function list() {
|
||||
return serverFetch<Pick<Blog,
|
||||
'id' | 'title' | 'slug' | 'description' | 'viewCount' | 'createdAt' | 'updatedAt'
|
||||
>[]>('/api/blog')
|
||||
}
|
||||
|
||||
export async function getBlogBySlug(slug: string, password?: string) {
|
||||
return serverFetch<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
content: string;
|
||||
}>(`/api/blog/${slug}/slug` + (password ? `?p=${password}` : ''));
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { PublicResource } from "@/lib/types/resource";
|
||||
import { serverFetch } from "../server";
|
||||
|
||||
export async function list() {
|
||||
return serverFetch<PublicResource[]>('/api/resource')
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { clientFetch } from "../client";
|
||||
import { APIError } from "../common";
|
||||
|
||||
export async function sendLoginSms(phone: string) {
|
||||
phone = phone.trim();
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new APIError('请输入合法的中国大陆手机号');
|
||||
}
|
||||
|
||||
return clientFetch('/api/sms/send/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone })
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { User } from "@/lib/types/user";
|
||||
import { clientFetch } from "../client";
|
||||
import { APIError } from "../common";
|
||||
|
||||
export async function me() {
|
||||
return clientFetch<User>('/api/user/me');
|
||||
}
|
||||
|
||||
export async function updatePassword(password: string) {
|
||||
if (! /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d!@#$%^&*()_+\-=\[\]{};:'",.<>/?]{6,32}$/.test(password)) {
|
||||
throw new APIError('新密码不符合规范,请重新输入')
|
||||
}
|
||||
|
||||
return clientFetch<null>('/api/user/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { User } from "@/lib/types/user";
|
||||
import { serverFetch } from "../server";
|
||||
|
||||
export async function me() {
|
||||
return serverFetch<User>('/api/user/me');
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { APIResponse, HttpMethod, normalizeAPIError } from './common';
|
||||
|
||||
interface ServerFetchRequestOptions extends RequestInit {
|
||||
method?: HttpMethod;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export async function serverFetch<T = unknown>(
|
||||
endpoint: string,
|
||||
options: ServerFetchRequestOptions = {}
|
||||
): Promise<T> {
|
||||
const cookieStore = await cookies();
|
||||
const cookieHeader = cookieStore.toString();
|
||||
|
||||
const reqHeaders = new Headers(await headers());
|
||||
const forwardedHeaders: Record<string, string> = {};
|
||||
['user-agent', 'x-forwarded-for', 'x-real-ip'].forEach((key) => {
|
||||
const value = reqHeaders.get(key);
|
||||
if (value) forwardedHeaders[key] = value;
|
||||
});
|
||||
|
||||
const defaultHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
||||
...forwardedHeaders,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(new URL(endpoint, process.env.API_BASE).href, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body ?? JSON.stringify(options.body),
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw JSON.parse(errorText);
|
||||
}
|
||||
|
||||
const data: APIResponse<T> = await response.json();
|
||||
if (!data.success) {
|
||||
throw data;
|
||||
}
|
||||
|
||||
return data.data as T;
|
||||
} catch (error) {
|
||||
normalizeAPIError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export * as BlogAPI from './endpoints/blog.server';
|
||||
export * as ResourceAPI from './endpoints/resource.server';
|
||||
export * as UserAPI from './endpoints/user.server';
|
||||
@@ -1,6 +0,0 @@
|
||||
export enum BlogPermission {
|
||||
Public = 'Public',
|
||||
ByPassword = 'ByPassword',
|
||||
List = 'List',
|
||||
AllowComments = 'AllowComments',
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { BlogPermission } from "./Blog.Permission.enum";
|
||||
|
||||
export interface Blog {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
viewCount: number;
|
||||
contentUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string;
|
||||
permissions: BlogPermission[];
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
{
|
||||
"name": "tone-page-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/ali-oss": "^6.16.13",
|
||||
"add": "^2.0.6",
|
||||
"alert": "^6.0.2",
|
||||
"ali-oss": "^6.23.0",
|
||||
"badge": "^1.0.3",
|
||||
"base-x": "^5.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dialog": "^0.3.1",
|
||||
"drawer": "^0.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"pagination": "^0.4.6",
|
||||
"popover": "^2.4.1",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"select": "^1.1.2",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"textarea": "^0.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.4",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/webappsec-credential-management": "^0.6.9",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 418 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
@@ -1,27 +0,0 @@
|
||||
// store/useUserStore.ts
|
||||
import { User } from '@/lib/types/user';
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
interface UserState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
initialized: boolean;
|
||||
|
||||
// Actions
|
||||
setInitialized: () => void;
|
||||
setUser: (user: User | null) => void;
|
||||
clearUser: () => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
devtools((set, get) => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
initialized: false,
|
||||
|
||||
setInitialized: () => set({ initialized: true }),
|
||||
setUser: (user) => set({ user, isLoading: false }),
|
||||
clearUser: () => set({ user: null }),
|
||||
}))
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user