diff --git a/.gitea/workflows/backend-docker.yml b/.gitea/workflows/backend-docker.yml new file mode 100644 index 0000000..1dfe842 --- /dev/null +++ b/.gitea/workflows/backend-docker.yml @@ -0,0 +1,183 @@ +name: docker-images + +on: + push: + branches: + - main + - master + paths: + - backend/** + - frontend/** + - admin/** + - deploy/docker/** + - .gitea/workflows/backend-docker.yml + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - component: backend + dockerfile: backend/Dockerfile + context: backend + default_image_name: termi-astro-backend + - component: frontend + dockerfile: frontend/Dockerfile + context: frontend + default_image_name: termi-astro-frontend + - component: admin + dockerfile: admin/Dockerfile + context: admin + default_image_name: termi-astro-admin + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve image metadata + id: meta + shell: bash + env: + COMPONENT: ${{ matrix.component }} + DEFAULT_IMAGE_NAME: ${{ matrix.default_image_name }} + VAR_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }} + VAR_IMAGE_NAMESPACE: ${{ vars.IMAGE_NAMESPACE }} + VAR_IMAGE_NAME: ${{ vars.IMAGE_NAME }} + VAR_BACKEND_IMAGE_NAME: ${{ vars.BACKEND_IMAGE_NAME }} + VAR_FRONTEND_IMAGE_NAME: ${{ vars.FRONTEND_IMAGE_NAME }} + VAR_ADMIN_IMAGE_NAME: ${{ vars.ADMIN_IMAGE_NAME }} + VAR_FRONTEND_PUBLIC_API_BASE_URL: ${{ vars.FRONTEND_PUBLIC_API_BASE_URL }} + VAR_ADMIN_VITE_API_BASE: ${{ vars.ADMIN_VITE_API_BASE }} + VAR_ADMIN_VITE_FRONTEND_BASE_URL: ${{ vars.ADMIN_VITE_FRONTEND_BASE_URL }} + VAR_ADMIN_VITE_BASENAME: ${{ vars.ADMIN_VITE_BASENAME }} + run: | + set -euo pipefail + + REGISTRY_HOST="${VAR_REGISTRY_HOST:-${GITEA_SERVER_URL#https://}}" + if [ -z "${REGISTRY_HOST}" ]; then + REGISTRY_HOST="git.init.cool" + fi + + REPO_OWNER="${GITHUB_REPOSITORY_OWNER:-${GITEA_REPOSITORY_OWNER:-cool}}" + IMAGE_NAMESPACE="${VAR_IMAGE_NAMESPACE:-${REPO_OWNER}}" + + case "${COMPONENT}" in + backend) + IMAGE_NAME="${VAR_BACKEND_IMAGE_NAME:-${VAR_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}}" + ;; + frontend) + IMAGE_NAME="${VAR_FRONTEND_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}" + ;; + admin) + IMAGE_NAME="${VAR_ADMIN_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}" + ;; + *) + IMAGE_NAME="${DEFAULT_IMAGE_NAME}" + ;; + esac + + REF_NAME="${GITHUB_REF_NAME:-${GITEA_REF_NAME:-main}}" + SAFE_REF="$(echo "${REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed 's#[^a-z0-9._-]#-#g')" + + COMMIT_SHA="${GITHUB_SHA:-${GITEA_SHA:-dev}}" + SHORT_SHA="$(echo "${COMMIT_SHA}" | cut -c1-12)" + + IMAGE_BASE="${REGISTRY_HOST}/${IMAGE_NAMESPACE}/${IMAGE_NAME}" + + FRONTEND_PUBLIC_API_BASE_URL="${VAR_FRONTEND_PUBLIC_API_BASE_URL:-http://localhost:5150/api}" + ADMIN_VITE_API_BASE="${VAR_ADMIN_VITE_API_BASE:-http://localhost:5150}" + ADMIN_VITE_FRONTEND_BASE_URL="${VAR_ADMIN_VITE_FRONTEND_BASE_URL:-http://localhost:4321}" + ADMIN_VITE_BASENAME="${VAR_ADMIN_VITE_BASENAME:-}" + + { + echo "registry_host=${REGISTRY_HOST}" + echo "image_base=${IMAGE_BASE}" + echo "tag_latest=latest" + echo "tag_branch=${SAFE_REF}" + echo "tag_sha=${SHORT_SHA}" + echo "frontend_public_api_base_url=${FRONTEND_PUBLIC_API_BASE_URL}" + echo "admin_vite_api_base=${ADMIN_VITE_API_BASE}" + echo "admin_vite_frontend_base_url=${ADMIN_VITE_FRONTEND_BASE_URL}" + echo "admin_vite_basename=${ADMIN_VITE_BASENAME}" + } >> "$GITHUB_OUTPUT" + + - name: Login registry + shell: bash + env: + REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }} + REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then + echo "Missing secrets: REGISTRY_USERNAME / REGISTRY_TOKEN" + exit 1 + fi + + echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" --username "${REGISTRY_USER}" --password-stdin + + - name: Build image + shell: bash + env: + COMPONENT: ${{ matrix.component }} + DOCKERFILE: ${{ matrix.dockerfile }} + CONTEXT_DIR: ${{ matrix.context }} + IMAGE_BASE: ${{ steps.meta.outputs.image_base }} + TAG_LATEST: ${{ steps.meta.outputs.tag_latest }} + TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }} + TAG_SHA: ${{ steps.meta.outputs.tag_sha }} + FRONTEND_PUBLIC_API_BASE_URL: ${{ steps.meta.outputs.frontend_public_api_base_url }} + ADMIN_VITE_API_BASE: ${{ steps.meta.outputs.admin_vite_api_base }} + ADMIN_VITE_FRONTEND_BASE_URL: ${{ steps.meta.outputs.admin_vite_frontend_base_url }} + ADMIN_VITE_BASENAME: ${{ steps.meta.outputs.admin_vite_basename }} + run: | + set -euo pipefail + + BUILD_ARGS=() + if [ "${COMPONENT}" = "frontend" ]; then + BUILD_ARGS+=(--build-arg "PUBLIC_API_BASE_URL=${FRONTEND_PUBLIC_API_BASE_URL}") + fi + + if [ "${COMPONENT}" = "admin" ]; then + BUILD_ARGS+=(--build-arg "VITE_API_BASE=${ADMIN_VITE_API_BASE}") + BUILD_ARGS+=(--build-arg "VITE_FRONTEND_BASE_URL=${ADMIN_VITE_FRONTEND_BASE_URL}") + BUILD_ARGS+=(--build-arg "VITE_ADMIN_BASENAME=${ADMIN_VITE_BASENAME}") + fi + + docker build \ + --file "${DOCKERFILE}" \ + "${BUILD_ARGS[@]}" \ + --tag "${IMAGE_BASE}:${TAG_LATEST}" \ + --tag "${IMAGE_BASE}:${TAG_BRANCH}" \ + --tag "${IMAGE_BASE}:${TAG_SHA}" \ + "${CONTEXT_DIR}" + + - name: Push image + shell: bash + env: + IMAGE_BASE: ${{ steps.meta.outputs.image_base }} + TAG_LATEST: ${{ steps.meta.outputs.tag_latest }} + TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }} + TAG_SHA: ${{ steps.meta.outputs.tag_sha }} + run: | + set -euo pipefail + docker push "${IMAGE_BASE}:${TAG_LATEST}" + docker push "${IMAGE_BASE}:${TAG_BRANCH}" + docker push "${IMAGE_BASE}:${TAG_SHA}" + + - name: Output image tags + shell: bash + env: + COMPONENT: ${{ matrix.component }} + IMAGE_BASE: ${{ steps.meta.outputs.image_base }} + TAG_LATEST: ${{ steps.meta.outputs.tag_latest }} + TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }} + TAG_SHA: ${{ steps.meta.outputs.tag_sha }} + run: | + echo "[${COMPONENT}] pushed tags:" + echo "- ${IMAGE_BASE}:${TAG_LATEST}" + echo "- ${IMAGE_BASE}:${TAG_BRANCH}" + echo "- ${IMAGE_BASE}:${TAG_SHA}" diff --git a/.gitignore b/.gitignore index a1973fc..0d4e44b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,24 @@ frontend/.astro/ frontend/dist/ frontend/node_modules/ +admin/dist/ +admin/node_modules/ +mcp-server/node_modules/ backend/target/ backend/.loco-start.err.log backend/.loco-start.out.log +backend/backend-start.log +backend/*.log +backend/*.err.log +backend/*.out.log +backend/storage/ai_embedding_models/ +backend-start.err +backend-start.log + +# local deployment/runtime artifacts +deploy/docker/.env +deploy/docker/config.yaml +admin/tmp-playwright.* +lighthouse-*/ +lighthouse-*.json diff --git a/README.md b/README.md index 15359c3..464736b 100644 --- a/README.md +++ b/README.md @@ -6,47 +6,81 @@ Monorepo for the Termi blog system. ```text . +├─ admin/ # React + shadcn admin workspace ├─ frontend/ # Astro blog frontend -├─ backend/ # Loco.rs backend and admin +├─ backend/ # Loco.rs backend APIs +├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags +├─ deploy/ # Deployment manifests (docker compose/env examples) ├─ .codex/ # Codex workspace config └─ .vscode/ # Editor workspace config ``` ## Run -### Monorepo scripts +### Recommended From the repository root: +```powershell +npm run dev +``` + +This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs. + +Common shortcuts: + +```powershell +npm run dev:mcp +npm run dev:frontend +npm run dev:admin +npm run dev:backend +npm run dev:mcp-only +npm run stop +npm run restart +``` + +### PowerShell entrypoint + +If you prefer direct scripts, use the single root entrypoint: + ```powershell .\dev.ps1 +.\dev.ps1 -WithMcp +.\dev.ps1 -Only frontend +.\dev.ps1 -Only admin +.\dev.ps1 -Only backend +.\dev.ps1 -Only mcp ``` -Only frontend: +If you want a single service to be opened as a new Windows Terminal tab instead of running in the current shell: ```powershell -.\dev.ps1 -FrontendOnly +.\dev.ps1 -Only frontend -Spawn ``` -Only backend: - -```powershell -.\dev.ps1 -BackendOnly -``` - -Direct scripts: +Legacy aliases are still available and now just forward to `dev.ps1`: ```powershell .\start-frontend.ps1 .\start-backend.ps1 +.\start-admin.ps1 +.\start-mcp.ps1 ``` ### Frontend ```powershell cd frontend -npm install -npm run dev +pnpm install +pnpm dev +``` + +### Admin + +```powershell +cd admin +pnpm install +pnpm dev ``` ### Backend @@ -57,6 +91,94 @@ $env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-ap cargo loco start 2>&1 ``` +### Docker(生产部署,使用 Gitea Package 镜像) + +补充部署分层与反代说明见: + +- `deploy/docker/ARCHITECTURE.md` +- `deploy/caddy/Caddyfile.tohka.example` + +```powershell +docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env up -d +``` + +当前 compose 默认启动: + +- frontend: `http://127.0.0.1:4321` +- admin: `http://127.0.0.1:4322` +- backend api: `http://127.0.0.1:5150` + +> 注意:`deploy/docker/compose.package.yml` 不内置 postgres/redis,需使用外部数据库与 Redis。 + +如果你不是直接用默认端口直连,而是走独立域名 / HTTPS / 反向代理,建议同时设置这些 compose 运行时变量: + +- `INTERNAL_API_BASE_URL=http://backend:5150/api` +- `PUBLIC_API_BASE_URL=https://api.blog.init.cool` +- `PUBLIC_IMAGE_ALLOWED_HOSTS=cdn.example.com,pub-xxxx.r2.dev` +- `ADMIN_API_BASE_URL=https://admin.blog.init.cool` +- `ADMIN_FRONTEND_BASE_URL=https://blog.init.cool` + +可复制 `deploy/docker/.env.example` 为 `deploy/docker/.env` 后,至少设置: + +- `DATABASE_URL` +- `REDIS_URL` +- `JWT_SECRET` + +如需覆盖镜像 tag: + +```powershell +$env:BACKEND_IMAGE="git.init.cool//termi-astro-backend:latest" +$env:FRONTEND_IMAGE="git.init.cool//termi-astro-frontend:latest" +$env:ADMIN_IMAGE="git.init.cool//termi-astro-admin:latest" +docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env up -d +``` + +### Gitea Actions Docker 发布 + +仓库已新增:`.gitea/workflows/backend-docker.yml` + +需要在仓库里配置: + +- Secrets + - `REGISTRY_USERNAME` + - `REGISTRY_TOKEN` +- Variables(可选) + - `REGISTRY_HOST`(默认 `git.init.cool`) + - `IMAGE_NAMESPACE`(默认仓库 owner) + - `BACKEND_IMAGE_NAME`(默认 `termi-astro-backend`) + - `FRONTEND_IMAGE_NAME`(默认 `termi-astro-frontend`) + - `ADMIN_IMAGE_NAME`(默认 `termi-astro-admin`) + - `FRONTEND_PUBLIC_API_BASE_URL`(frontend 镜像构建注入的浏览器侧 API 默认地址,默认 `http://localhost:5150/api`;运行时推荐优先使用 `PUBLIC_API_BASE_URL`) + - `ADMIN_VITE_API_BASE`(admin 镜像构建注入的 API 默认地址,默认 `http://localhost:5150`;运行时可被 `ADMIN_API_BASE_URL` 覆盖) + - `ADMIN_VITE_FRONTEND_BASE_URL`(admin 镜像构建注入的前台跳转默认基址,默认 `http://localhost:4321`;运行时可被 `ADMIN_FRONTEND_BASE_URL` 覆盖) + - `ADMIN_VITE_BASENAME`(可选;如果 admin 要挂在 `/admin` 这类路径前缀下,构建时设置为 `/admin`) + +### MCP Server + +```powershell +.\dev.ps1 -Only mcp +``` + +Default MCP endpoint: + +```text +http://127.0.0.1:5151/mcp +``` + +Default local development API key: + +```text +termi-mcp-local-dev-key +``` + +The MCP server wraps real backend APIs for: + +- Listing, reading, creating, updating, and deleting Markdown posts +- Listing, creating, updating, and deleting categories +- Listing, creating, updating, and deleting tags +- Reading and updating public site settings +- Rebuilding the AI index + ## Repo Name Recommended repository name: `termi-blog` diff --git a/admin/.dockerignore b/admin/.dockerignore new file mode 100644 index 0000000..d74bb8a --- /dev/null +++ b/admin/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.gitignore +*.log diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 0000000..2acfd09 --- /dev/null +++ b/admin/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-alpine AS builder +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +ARG VITE_API_BASE=http://localhost:5150 +ARG VITE_FRONTEND_BASE_URL=http://localhost:4321 +ARG VITE_ADMIN_BASENAME= +ENV VITE_API_BASE=${VITE_API_BASE} +ENV VITE_FRONTEND_BASE_URL=${VITE_FRONTEND_BASE_URL} +ENV VITE_ADMIN_BASENAME=${VITE_ADMIN_BASENAME} + +RUN pnpm build + +FROM nginx:1.27-alpine AS runner +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html +COPY docker-entrypoint.d/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh +RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh + +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1 +CMD ["nginx", "-g", "daemon off;"] diff --git a/admin/docker-entrypoint.d/40-runtime-config.sh b/admin/docker-entrypoint.d/40-runtime-config.sh new file mode 100644 index 0000000..8b2a97c --- /dev/null +++ b/admin/docker-entrypoint.d/40-runtime-config.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +RUNTIME_CONFIG_FILE="/usr/share/nginx/html/runtime-config.js" + +escape_js_string() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +API_BASE_URL="${ADMIN_API_BASE_URL:-}" +FRONTEND_BASE_URL="${ADMIN_FRONTEND_BASE_URL:-}" +ESCAPED_API_BASE_URL="$(escape_js_string "$API_BASE_URL")" +ESCAPED_FRONTEND_BASE_URL="$(escape_js_string "$FRONTEND_BASE_URL")" + +cat > "$RUNTIME_CONFIG_FILE" < + + + + + + + + + + Termi Admin + + +
+ + + + diff --git a/admin/nginx.conf b/admin/nginx.conf new file mode 100644 index 0000000..5470497 --- /dev/null +++ b/admin/nginx.conf @@ -0,0 +1,63 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + server_tokens off; + charset utf-8; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_min_length 1024; + gzip_comp_level 5; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/json + application/xml + application/xml+rss + application/manifest+json + image/svg+xml; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always; + + location = /healthz { + access_log off; + add_header Content-Type text/plain; + add_header Cache-Control "no-store"; + return 200 'ok'; + } + + location = /runtime-config.js { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location = /index.html { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } + + error_page 500 502 503 504 /50x.html; + + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..6929b5e --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,3515 @@ +{ + "name": "admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin", + "version": "0.0.0", + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/vite": "^4.2.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dompurify": "^3.3.3", + "lucide-react": "^1.7.0", + "marked": "^17.0.5", + "monaco-editor": "^0.55.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-router": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", + "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", + "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..7281a6a --- /dev/null +++ b/admin/package.json @@ -0,0 +1,44 @@ +{ + "name": "admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 4322", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/vite": "^4.2.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dompurify": "^3.3.3", + "lucide-react": "^1.7.0", + "marked": "^17.0.5", + "monaco-editor": "^0.55.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } +} diff --git a/admin/pnpm-lock.yaml b/admin/pnpm-lock.yaml new file mode 100644 index 0000000..ff034c8 --- /dev/null +++ b/admin/pnpm-lock.yaml @@ -0,0 +1,2304 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.14)(react@19.2.4) + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@8.0.3(@types/node@24.12.0)(jiti@2.6.1)) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dompurify: + specifier: ^3.3.3 + version: 3.3.3 + lucide-react: + specifier: ^1.7.0 + version: 1.7.0(react@19.2.4) + marked: + specifier: ^17.0.5 + version: 17.0.5 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + react-router-dom: + specifier: ^7.13.2 + version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 + devDependencies: + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@types/node': + specifier: ^24.12.0 + version: 24.12.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.3(@types/node@24.12.0)(jiti@2.6.1)) + eslint: + specifier: ^9.39.4 + version: 9.39.4(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@9.39.4(jiti@2.6.1)) + globals: + specifier: ^17.4.0 + version: 17.4.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.57.0 + version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^8.0.1 + version: 8.0.3(@types/node@24.12.0)(jiti@2.6.1) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/eslint-plugin@8.57.2': + resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.2': + resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.57.2': + resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.57.2': + resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.2': + resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.57.2': + resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.57.2': + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.2': + resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.57.2': + resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.57.2': + resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.11: + resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + + electron-to-chromium@1.5.328: + resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.7.0: + resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + marked@17.0.5: + resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==} + engines: {node: '>= 20'} + hasBin: true + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-router-dom@7.13.2: + resolution: {integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.2: + resolution: {integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.57.2: + resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.122.0': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@8.0.3(@types/node@24.12.0)(jiti@2.6.1))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 8.0.3(@types/node@24.12.0)(jiti@2.6.1) + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.57.2': + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.2': {} + + '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.2': + dependencies: + '@typescript-eslint/types': 8.57.2 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@types/node@24.12.0)(jiti@2.6.1))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.3(@types/node@24.12.0)(jiti@2.6.1) + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.11: {} + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.11 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.328 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001781: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dompurify@3.3.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + electron-to-chromium@1.5.328: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 9.39.4(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@9.39.4(jiti@2.6.1)): + dependencies: + eslint: 9.39.4(jiti@2.6.1) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.4.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.7.0(react@19.2.4): + dependencies: + react: 19.2.4 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@14.0.0: {} + + marked@17.0.5: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.36: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-router-dom@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-router: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react-router@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + cookie: 1.1.1 + react: 19.2.4 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + + react@19.2.4: {} + + resolve-from@4.0.0: {} + + rolldown@1.0.0-rc.12: + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + source-map-js@1.2.1: {} + + state-local@1.0.7: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.0.3(@types/node@24.12.0)(jiti@2.6.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.12 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/admin/public/favicon.svg b/admin/public/favicon.svg new file mode 100644 index 0000000..f157bd1 --- /dev/null +++ b/admin/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/admin/public/runtime-config.js b/admin/public/runtime-config.js new file mode 100644 index 0000000..48027e2 --- /dev/null +++ b/admin/public/runtime-config.js @@ -0,0 +1 @@ +window.__TERMI_ADMIN_RUNTIME_CONFIG__ = window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {} diff --git a/admin/src/App.tsx b/admin/src/App.tsx new file mode 100644 index 0000000..dee2d42 --- /dev/null +++ b/admin/src/App.tsx @@ -0,0 +1,389 @@ +import { + createContext, + lazy, + Suspense, + startTransition, + useContext, + useEffect, + useCallback, + useMemo, + useState, + type ReactNode, +} from 'react' +import { + BrowserRouter, + Navigate, + Outlet, + Route, + Routes, + useNavigate, +} from 'react-router-dom' +import { LoaderCircle } from 'lucide-react' +import { Toaster, toast } from 'sonner' + +import { AppShell } from '@/components/app-shell' +import { adminApi, ApiError } from '@/lib/api' +import type { AdminSessionResponse } from '@/lib/types' +import { LoginPage } from '@/pages/login-page' + +const DashboardPage = lazy(async () => { + const mod = await import('@/pages/dashboard-page') + return { default: mod.DashboardPage } +}) +const AnalyticsPage = lazy(async () => { + const mod = await import('@/pages/analytics-page') + return { default: mod.AnalyticsPage } +}) +const PostsPage = lazy(async () => { + const mod = await import('@/pages/posts-page') + return { default: mod.PostsPage } +}) +const RevisionsPage = lazy(async () => { + const mod = await import('@/pages/revisions-page') + return { default: mod.RevisionsPage } +}) +const CommentsPage = lazy(async () => { + const mod = await import('@/pages/comments-page') + return { default: mod.CommentsPage } +}) +const FriendLinksPage = lazy(async () => { + const mod = await import('@/pages/friend-links-page') + return { default: mod.FriendLinksPage } +}) +const MediaPage = lazy(async () => { + const mod = await import('@/pages/media-page') + return { default: mod.MediaPage } +}) +const ReviewsPage = lazy(async () => { + const mod = await import('@/pages/reviews-page') + return { default: mod.ReviewsPage } +}) +const SiteSettingsPage = lazy(async () => { + const mod = await import('@/pages/site-settings-page') + return { default: mod.SiteSettingsPage } +}) +const AuditPage = lazy(async () => { + const mod = await import('@/pages/audit-page') + return { default: mod.AuditPage } +}) +const SubscriptionsPage = lazy(async () => { + const mod = await import('@/pages/subscriptions-page') + return { default: mod.SubscriptionsPage } +}) + +type SessionContextValue = { + session: AdminSessionResponse + setSession: (session: AdminSessionResponse) => void + refreshSession: () => Promise +} + +const SessionContext = createContext(null) + +function useSession() { + const context = useContext(SessionContext) + + if (!context) { + throw new Error('useSession must be used inside SessionContext') + } + + return context +} + +function AppLoadingScreen() { + return ( +
+
+
+ +
+
+

+ Termi 后台 +

+

正在进入管理后台

+

+ 正在检查当前登录状态,并准备新的 React 管理工作台。 +

+
+
+
+ ) +} + +function RouteLoadingScreen() { + return ( +
+
+
+ +
+
+

正在加载页面模块

+

大型编辑器与工作台页面会按需加载。

+
+
+
+ ) +} + +function LazyRoute({ children }: { children: ReactNode }) { + return }>{children} +} + +function RequireAuth({ children }: { children: ReactNode }) { + const { session } = useSession() + + if (!session.authenticated) { + return + } + + return <>{children} +} + +function PublicOnly() { + const { session, setSession } = useSession() + const navigate = useNavigate() + const [submitting, setSubmitting] = useState(false) + + if (session.authenticated) { + return + } + + return ( + { + try { + setSubmitting(true) + const nextSession = await adminApi.login(payload) + startTransition(() => { + setSession(nextSession) + }) + toast.success('后台登录成功。') + navigate('/', { replace: true }) + } catch (error) { + toast.error(error instanceof ApiError ? error.message : '当前无法登录后台。') + } finally { + setSubmitting(false) + } + }} + /> + ) +} + +function ProtectedLayout() { + const { session, setSession } = useSession() + const navigate = useNavigate() + const [loggingOut, setLoggingOut] = useState(false) + + return ( + { + try { + setLoggingOut(true) + const nextSession = await adminApi.logout() + startTransition(() => { + setSession(nextSession) + }) + toast.success('已退出后台。') + navigate('/login', { replace: true }) + } catch (error) { + toast.error(error instanceof ApiError ? error.message : '当前无法退出后台。') + } finally { + setLoggingOut(false) + } + }} + > + + + ) +} + +function AppRoutes() { + return ( + + } /> + + + + } + > + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + } /> + + ) +} + +export default function App() { + const [session, setSession] = useState({ + authenticated: false, + username: null, + email: null, + auth_source: null, + auth_provider: null, + groups: [], + proxy_auth_enabled: false, + local_login_enabled: true, + can_logout: false, + }) + const [loading, setLoading] = useState(true) + + const refreshSession = useCallback(async () => { + try { + const nextSession = await adminApi.sessionStatus() + startTransition(() => { + setSession(nextSession) + }) + } catch (error) { + toast.error( + error instanceof ApiError ? error.message : '当前无法连接后台会话接口。', + ) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void refreshSession() + }, [refreshSession]) + + const contextValue = useMemo( + () => ({ + session, + setSession, + refreshSession, + }), + [session, refreshSession], + ) + + const basename = + ((import.meta.env.VITE_ADMIN_BASENAME as string | undefined)?.trim() || '').replace( + /\/$/, + '', + ) || undefined + + if (loading) { + return ( + <> + + + + ) + } + + return ( + + + + + + + ) +} diff --git a/admin/src/components/app-shell.tsx b/admin/src/components/app-shell.tsx new file mode 100644 index 0000000..68ab7fd --- /dev/null +++ b/admin/src/components/app-shell.tsx @@ -0,0 +1,273 @@ +import { + BarChart3, + BellRing, + BookOpenText, + ExternalLink, + History, + Image as ImageIcon, + LayoutDashboard, + Link2, + LogOut, + MessageSquareText, + Orbit, + ScrollText, + Settings, + Sparkles, +} from 'lucide-react' +import type { ReactNode } from 'react' +import { NavLink } from 'react-router-dom' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { buildFrontendUrl } from '@/lib/frontend-url' +import { cn } from '@/lib/utils' + +const primaryNav = [ + { + to: '/', + label: '概览', + description: '站点运营总览', + icon: LayoutDashboard, + }, + { + to: '/analytics', + label: '数据分析', + description: '搜索词与 AI 问答洞察', + icon: BarChart3, + }, + { + to: '/posts', + label: '文章', + description: 'Markdown 内容管理', + icon: ScrollText, + }, + { + to: '/revisions', + label: '版本', + description: '历史快照与一键回滚', + icon: History, + }, + { + to: '/comments', + label: '评论', + description: '审核与段落回复', + icon: MessageSquareText, + }, + { + to: '/friend-links', + label: '友链', + description: '友链申请与互链管理', + icon: Link2, + }, + { + to: '/reviews', + label: '评测', + description: '评测内容库', + icon: BookOpenText, + }, + { + to: '/media', + label: '媒体库', + description: '对象存储图片管理', + icon: ImageIcon, + }, + { + to: '/subscriptions', + label: '订阅', + description: '邮件 / Webhook 推送', + icon: BellRing, + }, + { + to: '/audit', + label: '审计', + description: '后台操作审计日志', + icon: Settings, + }, + { + to: '/settings', + label: '设置', + description: '品牌、资料与 AI 配置', + icon: Settings, + }, +] + +export function AppShell({ + children, + username, + email, + authSource, + authProvider, + loggingOut, + canLogout, + onLogout, +}: { + children: ReactNode + username: string | null + email: string | null + authSource: string | null + authProvider: string | null + loggingOut: boolean + canLogout: boolean + onLogout: () => Promise +}) { + return ( +
+
+ + +
+
+
+
+
+ + 新版管理工作台 +
+
+

+ 当前登录:{username ?? 'admin'} +

+

+ {authProvider ?? 'React + shadcn/ui 基础架构'} +

+ {email ? ( +

{email}

+ ) : authSource ? ( +

认证来源:{authSource}

+ ) : null} +
+
+ +
+ {primaryNav.map((item) => ( + + cn( + 'rounded-full border px-3 py-2 text-sm whitespace-nowrap transition-colors', + isActive + ? 'border-primary/30 bg-primary/10 text-primary' + : 'border-border/70 bg-background/60 text-muted-foreground', + ) + } + > + {item.label} + + ))} +
+ +
+ + +
+
+
+ +
{children}
+
+
+
+ ) +} diff --git a/admin/src/components/form-field.tsx b/admin/src/components/form-field.tsx new file mode 100644 index 0000000..8cd2f18 --- /dev/null +++ b/admin/src/components/form-field.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' + +import { Label } from '@/components/ui/label' + +export function FormField({ + label, + hint, + children, +}: { + label: string + hint?: string + children: ReactNode +}) { + return ( +
+ + {children} + {hint ?

{hint}

: null} +
+ ) +} diff --git a/admin/src/components/lazy-monaco.tsx b/admin/src/components/lazy-monaco.tsx new file mode 100644 index 0000000..1502928 --- /dev/null +++ b/admin/src/components/lazy-monaco.tsx @@ -0,0 +1,72 @@ +import { lazy, Suspense } from 'react' + +import type { DiffEditorProps, EditorProps } from '@monaco-editor/react' + +const MonacoEditor = lazy(async () => { + const mod = await import('@monaco-editor/react') + return { default: mod.default } +}) + +const MonacoDiffEditor = lazy(async () => { + const mod = await import('@monaco-editor/react') + return { default: mod.DiffEditor } +}) + +function MonacoLoading({ + height, + width, + className, + loading, +}: { + height?: string | number + width?: string | number + className?: string + loading?: React.ReactNode +}) { + return ( +
+ {loading ?? ( +
+ 正在加载编辑器... +
+ )} +
+ ) +} + +export function LazyEditor(props: EditorProps) { + return ( + + } + > + + + ) +} + +export function LazyDiffEditor(props: DiffEditorProps) { + return ( + + } + > + + + ) +} diff --git a/admin/src/components/markdown-preview.tsx b/admin/src/components/markdown-preview.tsx new file mode 100644 index 0000000..11edadf --- /dev/null +++ b/admin/src/components/markdown-preview.tsx @@ -0,0 +1,41 @@ +import DOMPurify from 'dompurify' +import { marked } from 'marked' +import { useDeferredValue, useMemo } from 'react' + +import { cn } from '@/lib/utils' + +type MarkdownPreviewProps = { + markdown: string + className?: string +} + +marked.setOptions({ + breaks: true, + gfm: true, +}) + +export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) { + const deferredMarkdown = useDeferredValue(markdown) + const html = useMemo(() => { + const rendered = marked.parse(deferredMarkdown || '暂无内容。') + return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '') + }, [deferredMarkdown]) + + return ( +
+
+
+ ) +} diff --git a/admin/src/components/markdown-workbench.tsx b/admin/src/components/markdown-workbench.tsx new file mode 100644 index 0000000..6a0e023 --- /dev/null +++ b/admin/src/components/markdown-workbench.tsx @@ -0,0 +1,335 @@ +import type { ReactNode } from 'react' +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' + +import type { BeforeMount } from '@monaco-editor/react' +import { Expand, Minimize2, Sparkles } from 'lucide-react' + +import { LazyDiffEditor, LazyEditor } from '@/components/lazy-monaco' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +export type MarkdownWorkbenchPanel = 'edit' | 'preview' | 'diff' +export type MarkdownWorkbenchMode = 'workspace' | 'polish' + +type MarkdownWorkbenchProps = { + value: string + originalValue: string + diffValue?: string + path: string + workspaceHeightClassName?: string + readOnly?: boolean + mode: MarkdownWorkbenchMode + visiblePanels: MarkdownWorkbenchPanel[] + availablePanels?: MarkdownWorkbenchPanel[] + allowPolish?: boolean + preview: ReactNode + polishPanel?: ReactNode + originalLabel?: string + modifiedLabel?: string + onChange: (value: string) => void + onModeChange: (next: MarkdownWorkbenchMode) => void + onVisiblePanelsChange: (next: MarkdownWorkbenchPanel[]) => void +} + +export const editorTheme = 'termi-vscode' + +const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff'] + +function formatPanelLabel(panel: MarkdownWorkbenchPanel) { + switch (panel) { + case 'preview': + return '预览' + case 'diff': + return '改动对比' + case 'edit': + default: + return '编辑' + } +} + +function resolveVisiblePanels( + visiblePanels: MarkdownWorkbenchPanel[], + availablePanels: MarkdownWorkbenchPanel[], +) { + const orderedAvailablePanels = orderedWorkbenchPanels.filter((panel) => + availablePanels.includes(panel), + ) + const nextPanels = orderedAvailablePanels.filter((panel) => visiblePanels.includes(panel)) + return nextPanels.length ? nextPanels : orderedAvailablePanels.slice(0, 1) +} + +export const configureMonaco: BeforeMount = (monaco) => { + monaco.editor.defineTheme(editorTheme, { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'comment', foreground: '6A9955' }, + { token: 'keyword', foreground: 'C586C0' }, + { token: 'string', foreground: 'CE9178' }, + { token: 'number', foreground: 'B5CEA8' }, + { token: 'delimiter', foreground: 'D4D4D4' }, + { token: 'type.identifier', foreground: '4EC9B0' }, + ], + colors: { + 'editor.background': '#1e1e1e', + 'editor.foreground': '#d4d4d4', + 'editor.lineHighlightBackground': '#2a2d2e', + 'editor.lineHighlightBorder': '#00000000', + 'editorCursor.foreground': '#aeafad', + 'editor.selectionBackground': '#264f78', + 'editor.inactiveSelectionBackground': '#3a3d41', + 'editorWhitespace.foreground': '#3b3b3b', + 'editorIndentGuide.background1': '#404040', + 'editorIndentGuide.activeBackground1': '#707070', + 'editorLineNumber.foreground': '#858585', + 'editorLineNumber.activeForeground': '#c6c6c6', + 'editorGutter.background': '#1e1e1e', + 'editorOverviewRuler.border': '#00000000', + 'diffEditor.insertedTextBackground': '#9ccc2c33', + 'diffEditor.removedTextBackground': '#ff6b6b2d', + 'diffEditor.insertedLineBackground': '#9ccc2c18', + 'diffEditor.removedLineBackground': '#ff6b6b18', + }, + }) +} + +export const sharedOptions = { + automaticLayout: true, + fontFamily: + '"JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, SFMono-Regular, monospace', + fontLigatures: true, + fontSize: 14, + lineHeight: 22, + minimap: { enabled: false }, + padding: { top: 16, bottom: 16 }, + renderWhitespace: 'selection' as const, + roundedSelection: false, + scrollBeyondLastLine: false, + smoothScrolling: true, + tabSize: 2, + wordWrap: 'on' as const, +} + +export function MarkdownWorkbench({ + value, + originalValue, + diffValue, + path, + workspaceHeightClassName = 'h-[560px]', + readOnly = false, + mode, + visiblePanels, + availablePanels = ['edit', 'preview', 'diff'], + allowPolish, + preview, + polishPanel, + originalLabel = '基线版本', + modifiedLabel = '目标版本', + onChange, + onModeChange, + onVisiblePanelsChange, +}: MarkdownWorkbenchProps) { + const [fullscreen, setFullscreen] = useState(false) + const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : workspaceHeightClassName + const diffContent = diffValue ?? value + const polishEnabled = allowPolish ?? Boolean(polishPanel) + const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels) + const renderDiffSideBySide = workspacePanels.length < 3 || fullscreen + + useEffect(() => { + if (!fullscreen) { + return + } + + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = previousOverflow + } + }, [fullscreen]) + + const togglePanel = (panel: MarkdownWorkbenchPanel) => { + const currentPanels = resolveVisiblePanels(visiblePanels, availablePanels) + const nextPanels = currentPanels.includes(panel) + ? currentPanels.filter((item) => item !== panel) + : orderedWorkbenchPanels.filter( + (item) => availablePanels.includes(item) && (currentPanels.includes(item) || item === panel), + ) + + onVisiblePanelsChange(nextPanels.length ? nextPanels : availablePanels.slice(0, 1)) + + if (mode !== 'workspace') { + onModeChange('workspace') + } + } + + const workbench = ( +
+
+
+
+ + + +
+

{path}

+
+ +
+ {availablePanels.map((panel) => { + const active = mode === 'workspace' && workspacePanels.includes(panel) + + return ( + + ) + })} + {polishEnabled ? ( + + ) : null} + +
+
+ +
+ {mode === 'polish' ? ( +
{polishPanel}
+ ) : ( +
+ {workspacePanels.map((panel, index) => ( +
+
+ {formatPanelLabel(panel)} + {panel === 'diff' ? ( + + {originalLabel} / {modifiedLabel} + + ) : ( + {path} + )} +
+ + {panel === 'edit' ? ( +
+ onChange(next ?? '')} + /> +
+ ) : null} + + {panel === 'preview' ? ( +
{preview}
+ ) : null} + + {panel === 'diff' ? ( +
+ +
+ ) : null} +
+ ))} +
+ )} +
+
+ ) + + if (!fullscreen) { + return workbench + } + + if (typeof document === 'undefined') { + return workbench + } + + return createPortal( + <> +
+
{workbench}
+ , + document.body, + ) +} diff --git a/admin/src/components/ui/badge.tsx b/admin/src/components/ui/badge.tsx new file mode 100644 index 0000000..c31d0d4 --- /dev/null +++ b/admin/src/components/ui/badge.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] transition-colors', + { + variants: { + variant: { + default: 'border-primary/20 bg-primary/10 text-primary', + secondary: 'border-border bg-secondary text-secondary-foreground', + outline: 'border-border/80 bg-background/60 text-muted-foreground', + success: 'border-emerald-500/20 bg-emerald-500/12 text-emerald-600', + warning: 'border-amber-500/20 bg-amber-500/12 text-amber-700', + danger: 'border-rose-500/20 bg-rose-500/12 text-rose-600', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Badge({ + className, + variant, + ...props +}: React.HTMLAttributes & VariantProps) { + return
+} + +export { Badge, badgeVariants } diff --git a/admin/src/components/ui/button.tsx b/admin/src/components/ui/button.tsx new file mode 100644 index 0000000..cdc3329 --- /dev/null +++ b/admin/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)] hover:bg-primary/90', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + outline: + 'border border-border bg-background/80 text-foreground hover:bg-accent hover:text-accent-foreground', + ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground', + danger: + 'bg-destructive text-destructive-foreground shadow-[0_12px_30px_rgb(220_38_38_/_0.18)] hover:bg-destructive/90', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-xl px-5', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + + return ( + + ) + }, +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/admin/src/components/ui/card.tsx b/admin/src/components/ui/card.tsx new file mode 100644 index 0000000..74ea19e --- /dev/null +++ b/admin/src/components/ui/card.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +CardFooter.displayName = 'CardFooter' + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } diff --git a/admin/src/components/ui/input.tsx b/admin/src/components/ui/input.tsx new file mode 100644 index 0000000..c395538 --- /dev/null +++ b/admin/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + }, +) +Input.displayName = 'Input' + +export { Input } diff --git a/admin/src/components/ui/label.tsx b/admin/src/components/ui/label.tsx new file mode 100644 index 0000000..ea3c965 --- /dev/null +++ b/admin/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70') + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/admin/src/components/ui/select.tsx b/admin/src/components/ui/select.tsx new file mode 100644 index 0000000..e0248c5 --- /dev/null +++ b/admin/src/components/ui/select.tsx @@ -0,0 +1,502 @@ +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { Check, ChevronDown } from 'lucide-react' + +import { cn } from '@/lib/utils' + +type NativeSelectProps = React.ComponentProps<'select'> + +type SelectOption = { + value: string + label: React.ReactNode + disabled: boolean +} + +type MenuPlacement = 'top' | 'bottom' + +function normalizeValue(value: NativeSelectProps['value'] | NativeSelectProps['defaultValue']) { + if (Array.isArray(value)) { + return value[0] == null ? '' : String(value[0]) + } + + return value == null ? '' : String(value) +} + +function extractOptions(children: React.ReactNode) { + const options: SelectOption[] = [] + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child) || child.type !== 'option') { + return + } + + const props = child.props as React.OptionHTMLAttributes & { + children?: React.ReactNode + } + + options.push({ + value: normalizeValue(props.value), + label: props.children, + disabled: Boolean(props.disabled), + }) + }) + + return options +} + +function getFirstEnabledIndex(options: SelectOption[]) { + return options.findIndex((option) => !option.disabled) +} + +function getLastEnabledIndex(options: SelectOption[]) { + for (let index = options.length - 1; index >= 0; index -= 1) { + if (!options[index]?.disabled) { + return index + } + } + + return -1 +} + +function getNextEnabledIndex(options: SelectOption[], currentIndex: number, direction: 1 | -1) { + if (options.length === 0) { + return -1 + } + + let index = currentIndex + + for (let step = 0; step < options.length; step += 1) { + index = (index + direction + options.length) % options.length + if (!options[index]?.disabled) { + return index + } + } + + return -1 +} + +const Select = React.forwardRef( + ( + { + children, + className, + defaultValue, + disabled = false, + id, + onBlur, + onClick, + onFocus, + onKeyDown, + value, + ...props + }, + forwardedRef, + ) => { + const options = React.useMemo(() => extractOptions(children), [children]) + const isControlled = value !== undefined + const initialValue = React.useMemo(() => { + if (defaultValue !== undefined) { + return normalizeValue(defaultValue) + } + + return options[0]?.value ?? '' + }, [defaultValue, options]) + + const [internalValue, setInternalValue] = React.useState(initialValue) + const [open, setOpen] = React.useState(false) + const [highlightedIndex, setHighlightedIndex] = React.useState(-1) + const [menuPlacement, setMenuPlacement] = React.useState('bottom') + const [menuStyle, setMenuStyle] = React.useState(null) + + const wrapperRef = React.useRef(null) + const triggerRef = React.useRef(null) + const nativeSelectRef = React.useRef(null) + const menuRef = React.useRef(null) + const optionRefs = React.useRef>([]) + const menuId = React.useId() + + const currentValue = isControlled ? normalizeValue(value) : internalValue + const selectedIndex = options.findIndex((option) => option.value === currentValue) + const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : options[0] ?? null + + React.useEffect(() => { + if (!isControlled && options.length > 0 && !options.some((option) => option.value === internalValue)) { + setInternalValue(options[0]?.value ?? '') + } + }, [internalValue, isControlled, options]) + + const updateMenuPosition = React.useCallback(() => { + const trigger = triggerRef.current + if (!trigger) { + return + } + + const rect = trigger.getBoundingClientRect() + const viewportPadding = 12 + const gutter = 6 + const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320) + const spaceBelow = window.innerHeight - rect.bottom - viewportPadding + const spaceAbove = rect.top - viewportPadding + const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow + const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320)) + const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2) + const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding) + + setMenuPlacement(openToTop ? 'top' : 'bottom') + setMenuStyle( + openToTop + ? { + left, + width, + maxHeight, + bottom: window.innerHeight - rect.top + gutter, + } + : { + left, + width, + maxHeight, + top: rect.bottom + gutter, + }, + ) + }, [options.length]) + + const setOpenWithHighlight = React.useCallback( + (nextOpen: boolean, preferredIndex?: number) => { + if (disabled) { + return + } + + if (nextOpen) { + const fallbackIndex = + preferredIndex ?? + (selectedIndex >= 0 && !options[selectedIndex]?.disabled + ? selectedIndex + : getFirstEnabledIndex(options)) + + setHighlightedIndex(fallbackIndex) + updateMenuPosition() + setOpen(true) + return + } + + setOpen(false) + }, + [disabled, options, selectedIndex, updateMenuPosition], + ) + + const commitValue = React.useCallback( + (nextIndex: number) => { + const option = options[nextIndex] + const nativeSelect = nativeSelectRef.current + + if (!option || option.disabled) { + return + } + + if (!isControlled) { + setInternalValue(option.value) + } + + if (nativeSelect && currentValue !== option.value) { + nativeSelect.value = option.value + nativeSelect.dispatchEvent(new Event('change', { bubbles: true })) + } + + setOpen(false) + window.requestAnimationFrame(() => { + triggerRef.current?.focus() + }) + }, + [currentValue, isControlled, options], + ) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + onKeyDown?.(event as unknown as React.KeyboardEvent) + if (event.defaultPrevented || disabled) { + return + } + + switch (event.key) { + case 'ArrowDown': { + event.preventDefault() + if (!open) { + const nextIndex = + selectedIndex >= 0 + ? getNextEnabledIndex(options, selectedIndex, 1) + : getFirstEnabledIndex(options) + setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getFirstEnabledIndex(options)) + return + } + + setHighlightedIndex((current) => getNextEnabledIndex(options, current, 1)) + return + } + case 'ArrowUp': { + event.preventDefault() + if (!open) { + const nextIndex = + selectedIndex >= 0 + ? getNextEnabledIndex(options, selectedIndex, -1) + : getLastEnabledIndex(options) + setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getLastEnabledIndex(options)) + return + } + + setHighlightedIndex((current) => getNextEnabledIndex(options, current, -1)) + return + } + case 'Home': { + event.preventDefault() + const firstIndex = getFirstEnabledIndex(options) + if (!open) { + setOpenWithHighlight(true, firstIndex) + return + } + setHighlightedIndex(firstIndex) + return + } + case 'End': { + event.preventDefault() + const lastIndex = getLastEnabledIndex(options) + if (!open) { + setOpenWithHighlight(true, lastIndex) + return + } + setHighlightedIndex(lastIndex) + return + } + case 'Enter': + case ' ': { + event.preventDefault() + if (!open) { + setOpenWithHighlight(true) + return + } + + if (highlightedIndex >= 0) { + commitValue(highlightedIndex) + } + return + } + case 'Escape': { + if (!open) { + return + } + event.preventDefault() + setOpen(false) + return + } + case 'Tab': { + setOpen(false) + return + } + default: + return + } + }, + [commitValue, disabled, highlightedIndex, onKeyDown, open, options, selectedIndex, setOpenWithHighlight], + ) + + React.useLayoutEffect(() => { + if (!open) { + return + } + + updateMenuPosition() + }, [open, updateMenuPosition]) + + React.useEffect(() => { + if (!open) { + return + } + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node + if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) { + return + } + + setOpen(false) + } + + const handleWindowChange = () => updateMenuPosition() + + document.addEventListener('mousedown', handlePointerDown) + window.addEventListener('resize', handleWindowChange) + window.addEventListener('scroll', handleWindowChange, true) + + return () => { + document.removeEventListener('mousedown', handlePointerDown) + window.removeEventListener('resize', handleWindowChange) + window.removeEventListener('scroll', handleWindowChange, true) + } + }, [open, updateMenuPosition]) + + React.useEffect(() => { + if (!open || highlightedIndex < 0) { + return + } + + optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' }) + }, [highlightedIndex, open]) + + const triggerClasses = cn( + 'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/20 data-[state=open]:bg-card/95 data-[state=open]:shadow-[0_16px_36px_rgb(15_23_42_/_0.10)]', + className, + ) + + const menu = open && menuStyle + ? ReactDOM.createPortal( +
+
+ {options.map((option, index) => { + const selected = option.value === currentValue + const highlighted = index === highlightedIndex + + return ( + + ) + })} +
+
, + document.body, + ) + : null + + return ( +
+ + + + + {menu} +
+ ) + }, +) +Select.displayName = 'Select' + +export { Select } diff --git a/admin/src/components/ui/separator.tsx b/admin/src/components/ui/separator.tsx new file mode 100644 index 0000000..1205b0b --- /dev/null +++ b/admin/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Separator({ + className, + orientation = 'horizontal', + decorative = true, + ...props +}: React.HTMLAttributes & { + orientation?: 'horizontal' | 'vertical' + decorative?: boolean +}) { + return ( +
+ ) +} + +export { Separator } diff --git a/admin/src/components/ui/skeleton.tsx b/admin/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..04270f5 --- /dev/null +++ b/admin/src/components/ui/skeleton.tsx @@ -0,0 +1,9 @@ +import type { HTMLAttributes } from 'react' + +import { cn } from '@/lib/utils' + +function Skeleton({ className, ...props }: HTMLAttributes) { + return
+} + +export { Skeleton } diff --git a/admin/src/components/ui/table.tsx b/admin/src/components/ui/table.tsx new file mode 100644 index 0000000..ca64036 --- /dev/null +++ b/admin/src/components/ui/table.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +) +Table.displayName = 'Table' + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = 'TableHeader' + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = 'TableBody' + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +) +TableRow.displayName = 'TableRow' + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = 'TableHead' + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = 'TableCell' + +export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } diff --git a/admin/src/components/ui/textarea.tsx b/admin/src/components/ui/textarea.tsx new file mode 100644 index 0000000..a5a4d07 --- /dev/null +++ b/admin/src/components/ui/textarea.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Textarea = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( + - -
-
- -
-
这里保存的是服务器上的原始 Markdown 文件。你也可以直接在服务器用编辑器打开这个路径修改。
-
-
- - -{% endblock %} - -{% block page_scripts %} - -{% endblock %} diff --git a/backend/assets/views/admin/posts.html b/backend/assets/views/admin/posts.html deleted file mode 100644 index 87cdc0d..0000000 --- a/backend/assets/views/admin/posts.html +++ /dev/null @@ -1,199 +0,0 @@ -{% extends "admin/base.html" %} - -{% block main_content %} -
-
-
-

新建 Markdown 文章

-
直接生成 `content/posts/*.md` 文件,后端会自动解析 frontmatter、同步分类和标签。
-
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - - -
-
-
-
- -
-
-
-

导入 Markdown 文件

-
支持选择单个 `.md/.markdown` 文件,也支持直接选择一个本地 Markdown 文件夹批量导入。
-
-
- -
-
- - -
-
- - -
-
-
- -
-
导入时会从 frontmatter 和正文里提取标题、slug、摘要、分类、标签与内容,并写入服务器 `content/posts`。
-
-
-
-
- -
-
-
-

内容列表

-
直接跳到前台文章、分类筛选和 API 明细。
-
-
- - {% if rows | length > 0 %} -
- - - - - - - - - - - - - {% for row in rows %} - - - - - - - - - {% endfor %} - -
ID文章分类标签时间跳转
#{{ row.id }} -
- {{ row.title }} - {{ row.slug }} - {{ row.file_path }} -
-
-
- {{ row.category_name }} - 查看该分类文章 -
-
- - {{ row.created_at }} - -
-
- {% else %} -
当前没有可管理的文章数据。
- {% endif %} -
-{% endblock %} - -{% block page_scripts %} - -{% endblock %} diff --git a/backend/assets/views/admin/reviews.html b/backend/assets/views/admin/reviews.html deleted file mode 100644 index ca7cdf8..0000000 --- a/backend/assets/views/admin/reviews.html +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "admin/base.html" %} - -{% block main_content %} -
-
-
-

新增评价

-
这里创建的评价会立刻出现在前台 `/reviews` 页面。
-
-
- -
-
- - - - - - - - -
-
- -
-
-
- -
-
-
-

评价列表

-
这里的每一行都可以直接编辑,保存后前台评价页会读取最新数据。
-
-
- - {% if rows | length > 0 %} -
- - - - - - - - - - - {% for row in rows %} - - - - - - - {% endfor %} - -
ID评价内容状态操作
#{{ row.id }} -
-
- - - - - - - - -
-
- - API -
-
-
{{ row.status }} -
- 前台查看 -
- -
-
-
-
- {% else %} -
暂无评价数据。
- {% endif %} -
-{% endblock %} diff --git a/backend/assets/views/admin/site_settings.html b/backend/assets/views/admin/site_settings.html deleted file mode 100644 index 1f5cdbe..0000000 --- a/backend/assets/views/admin/site_settings.html +++ /dev/null @@ -1,143 +0,0 @@ -{% extends "admin/base.html" %} - -{% block main_content %} -
-
-
-

站点资料

-
保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。
-
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
保存后可直接点击顶部“预览首页 / 预览关于页 / 预览友链页”确认前台展示。
-
-
-
-
-{% endblock %} - -{% block page_scripts %} - -{% endblock %} diff --git a/backend/assets/views/admin/tags.html b/backend/assets/views/admin/tags.html deleted file mode 100644 index 12aa77b..0000000 --- a/backend/assets/views/admin/tags.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "admin/base.html" %} - -{% block main_content %} -
-
-
-

新增标签

-
这里维护标签字典。文章 Markdown 导入时会优先复用这里的标签,不存在才自动创建。
-
-
- -
-
- - -
-
- -
-
-
- -
-
-
-

标签映射

-
标签名称会作为文章展示名称使用,使用次数来自当前已同步的真实文章内容。
-
-
- - {% if rows | length > 0 %} -
- - - - - - - - - - - {% for row in rows %} - - - - - - - {% endfor %} - -
ID标签使用次数跳转
#{{ row.id }} -
-
- - -
-
- - API -
-
-
{{ row.usage_count }} 篇文章 -
- 前台标签页 - 前台筛选 -
- -
-
-
-
- {% else %} -
暂无标签数据。
- {% endif %} -
-{% endblock %} diff --git a/backend/assets/views/home/hello.html b/backend/assets/views/home/hello.html deleted file mode 100644 index 6b97c39..0000000 --- a/backend/assets/views/home/hello.html +++ /dev/null @@ -1,12 +0,0 @@ - - -
- find this tera template at assets/views/home/hello.html: -
-
- {{ t(key="hello-world", lang="en-US") }}, -
- {{ t(key="hello-world", lang="de-DE") }} - - - \ No newline at end of file diff --git a/backend/config/development.yaml b/backend/config/development.yaml index 99cae81..e177800 100644 --- a/backend/config/development.yaml +++ b/backend/config/development.yaml @@ -31,8 +31,6 @@ server: folder: uri: "/static" path: "assets/static" - # fallback to index.html which redirects to /admin - fallback: "assets/static/index.html" # Worker Configuration workers: diff --git a/backend/config/production.yaml b/backend/config/production.yaml new file mode 100644 index 0000000..2410fde --- /dev/null +++ b/backend/config/production.yaml @@ -0,0 +1,59 @@ +logger: + enable: true + pretty_backtrace: false + level: info + format: json + +server: + port: {{ get_env(name="PORT", default="5150") }} + binding: 0.0.0.0 + host: {{ get_env(name="APP_BASE_URL", default="http://localhost:5150") }} + middlewares: + static: + enable: true + must_exist: true + precompressed: false + folder: + uri: "/static" + path: "assets/static" + +workers: + mode: BackgroundQueue + +queue: + kind: Redis + uri: {{ get_env(name="REDIS_URL", default="redis://redis:6379") }} + dangerously_flush: false + +mailer: + smtp: + enable: {{ get_env(name="SMTP_ENABLE", default="false") }} + host: '{{ get_env(name="SMTP_HOST", default="localhost") }}' + port: {{ get_env(name="SMTP_PORT", default="1025") }} + secure: {{ get_env(name="SMTP_SECURE", default="false") }} + {% set smtp_user = get_env(name="SMTP_USER", default="") %} + {% if smtp_user != "" %} + auth: + user: '{{ smtp_user }}' + password: '{{ get_env(name="SMTP_PASSWORD", default="") }}' + {% endif %} + {% set smtp_hello_name = get_env(name="SMTP_HELLO_NAME", default="") %} + {% if smtp_hello_name != "" %} + hello_name: '{{ smtp_hello_name }}' + {% endif %} + +database: + uri: {{ get_env(name="DATABASE_URL", default="postgres://termi:termi@db:5432/termi_api") }} + enable_logging: false + connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }} + idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }} + min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }} + max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="10") }} + auto_migrate: true + dangerously_truncate: false + dangerously_recreate: false + +auth: + jwt: + secret: {{ get_env(name="JWT_SECRET", default="please-change-me") }} + expiration: {{ get_env(name="JWT_EXPIRATION_SECONDS", default="604800") }} diff --git a/backend/config/test.yaml b/backend/config/test.yaml index 67149fe..b7d45f5 100644 --- a/backend/config/test.yaml +++ b/backend/config/test.yaml @@ -29,7 +29,6 @@ server: folder: uri: "/static" path: "assets/static" - fallback: "assets/static/404.html" # Worker Configuration workers: diff --git a/backend/content/posts/building-blog-with-astro.md b/backend/content/posts/building-blog-with-astro.md deleted file mode 100644 index f66cb70..0000000 --- a/backend/content/posts/building-blog-with-astro.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Building a Blog with Astro -slug: building-blog-with-astro -description: Learn why Astro is the perfect choice for building fast, content-focused blogs. -category: tech -post_type: article -pinned: false -published: true -tags: - - astro - - web-dev - - static-site ---- - -# Building a Blog with Astro - -Astro is a modern static site generator that delivers lightning-fast performance. - -## Why Astro? - -- Zero JavaScript by default -- Island Architecture -- Framework Agnostic -- Great DX - -## Getting Started - -```bash -npm create astro@latest -cd my-astro-project -npm install -npm run dev -``` - -## Conclusion - -Astro is perfect for content-focused websites like blogs. diff --git a/backend/content/posts/canokeys.md b/backend/content/posts/canokeys.md new file mode 100644 index 0000000..0c48a83 --- /dev/null +++ b/backend/content/posts/canokeys.md @@ -0,0 +1,242 @@ +--- +title: "Canokey入门指南:2FA、OpenPGP、PIV" +description: 本文是一份Canokey入门指南,将介绍如何使用Canokey进行2FA、OpenPGP和PIV等操作。其中,2FA部分将介绍如何使用Yubikey Authenticator进行管理,OpenPGP部分将介绍如何生成GPG密钥并使用Canokey进行身份验证和加密解密,PIV部分将介绍如何在Canokey中生成PIV证书并使用其进行身份验证。 +date: 2022-08-19T16:42:40+08:00 +draft: false +slug: canokeys +image: +categories: + - Linux +tags: + - Linux +--- + + + +# 2FA + +`Canokey`使用`Yubikey Authenticator`来进行管理`2FA`。 + +下载`Yubikey Authenticator`,以下为`Yubikey Authenticator`官方下载网址 + +```http +https://www.yubico.com/products/yubico-authenticator/#h-download-yubico-authenticator +``` + +运行`Yubikey Authenticator` + +进入`custom reader`,在`Custom reader fiter`处填入 `CanoKey` + +![填入CanoKey](https://upload-images.jianshu.io/upload_images/9676051-ff0cd60f38ac7334.png) + +右上角`Add account` 增加`2FA` + +![添加2FA](https://upload-images.jianshu.io/upload_images/9676051-1031857fe0f13d08.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +```yaml +Issuer: 备注 可选 +Account name : 用户名 必填项 +Secret Key : Hotp或Totp的key 必填项 +``` + + +# OpenPGP + +## 安装GPG + +Windows 用户可下载 [Gpg4Win](https://gpg4win.org/download.html),Linux/macOS 用户使用对应包管理软件安装即可. + +## 生成主密钥 + +```shell +gpg --expert --full-gen-key #生成GPG KEY +``` + +推荐使用`ECC`算法 + +![image-20220102223722475](https://upload-images.jianshu.io/upload_images/9676051-df42e4b958e9a238.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +```shell +选择(11) ECC (set your own capabilities) # 设置自己的功能 主密钥只保留 Certify 功能,其他功能(Encr,Sign,Auth)使用子密钥 +# 子密钥分成三份,分别获得三个不同的功能 +# encr 解密功能 +# sign 签名功能 +# auth 登录验证功能 +``` + +```shell +先选择 (S) Toggle the sign capability +``` + +![image-20220102224151589](https://upload-images.jianshu.io/upload_images/9676051-c3bb19eb398419e1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +``` +之后输入q 退出 +``` + +键入1,选择默认算法 + +![键入1,选择默认算法](https://upload-images.jianshu.io/upload_images/9676051-7a2c5ee8ed4800af.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +设置主密钥永不过期 + +![image-20220102224451731](https://upload-images.jianshu.io/upload_images/9676051-cca6100917c2ffaa.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +填写信息,按照实际情况填写即可 + +![image-20220102224612167](https://upload-images.jianshu.io/upload_images/9676051-10430afe3aa592c7.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +``` +Windnows 下会弹出窗口输入密码,注意一定要保管好!!! +``` + +```shell + +``` + +```shell +# 会自动生成吊销证书,注意保存到安全的地方 +gpg: AllowSetForegroundWindow(22428) failed: �ܾ����ʡ� +gpg: revocation certificate stored as 'C:\\Users\\Andorid\\AppData\\Roaming\\gnupg\\openpgp-revocs.d\\<此处为私钥>.rev' +# 以上的REV文件即为吊销证书 +public and secret key created and signed. +``` + +```shell +pub ed25519 2022-01-02 [SC] + <此处为Pub> +uid <此处为Name> <此处为email> +``` + +生成子密钥 + +```shell + gpg --fingerprint --keyid-format long -K +``` + +下面生成不同功能的子密钥,其中 `` 为上面输出的密钥指纹,本示例中即为 `私钥`。最后的 `2y` 为密钥过期时间,可自行设置,如不填写默认永不过期。 + +```shell +gpg --quick-add-key cv25519 encr 2y +gpg --quick-add-key ed25519 auth 2y +gpg --quick-add-key ed25519 sign 2y +``` + +再次查看目前的私钥,可以看到已经包含了这三个子密钥。 + +```shell +gpg --fingerprint --keyid-format long -K +``` + +上面生成了三种功能的子密钥(ssb),分别为加密(E)、认证(A)、签名(S),对应 `OpenPGP Applet` 中的三个插槽。由于 `ECC` 实现的原因,加密密钥的算法区别于其他密钥的算法。 + +加密密钥用于加密文件和信息。签名密钥主要用于给自己的信息签名,保证这真的是来自**我**的信息。认证密钥主要用于 SSH 登录。 + +## 备份GPG + +```shell +# 公钥 +gpg -ao public-key.pub --export +# 主密钥,请务必保存好!!! +# 注意 key id 后面的 !,表示只导出这一个私钥,若没有的话默认导出全部私钥。 +gpg -ao sec-key.asc --export-secret-key ! +# sign子密钥 +gpg -ao sign-key.asc --export-secret-key ! +gpg -ao auth-key.asc --export-secret-key ! +gpg -ao encr-key.asc --export-secret-key ! +``` + +## 导入Canokey + +```shell +# 查看智能卡设备状态 +gpg --card-status +# 写入GPG +gpg --edit-key # 为上方的sec-key +# 选中第一个子密钥 +key 1 +# 写入到智能卡 +keytocard +# 再次输入,取消选择 +key 1 +# 选择第二个子密钥 +key 2 +keytocard +key 2 +key 3 +keytocard +# 保存修改并退出 +save + +#再次查看设备状态,可以看到此时子密钥标识符为 ssb>,表示本地只有一个指向 card-no: F1D0 xxxxxxxx 智能卡的指针,已不存在私钥。现在可以删除掉主密钥了,请再次确认你已安全备份好主密钥。 +gpg --card-status +``` +## 删除本地密钥 + +```shell +gpg --delete-secret-keys # 为上方的sec-key +``` + +为确保安全,也可直接删除 gpg 的工作目录:`%APPDATA%\gnupg`,Linux/macOS: `~/.gunpg`。 + +## 使用 Canokey + +此时切换回日常使用的环境,首先导入公钥 + +```shell +gpg --import public-key.pub +``` + +然后设置子密钥指向 Canokey + +```shell +gpg --edit-card +gpg/card> fetch +``` + +此时查看本地的私钥,可以看到已经指向了 Canokey + +``` +gpg --fingerprint --keyid-format long -K +``` + +配置gpg路径 + +```bash +git config --global gpg.program "C:\Program Files (x86)\GnuPG\bin\gpg.exe" --replace-all +``` + +## Git Commit 签名 + +首先确保 Git 本地配置以及 GitHub 中的邮箱信息包含在 `UID` 中,然后设置 Git 来指定使用子密钥中的签名(S)密钥。 + +```shell +git config --global user.signingkey # 为上方的Sign密钥 +``` + +之后在 `git commit` 时增加 `-S` 参数即可使用 gpg 进行签名。也可在配置中设置自动 gpg 签名,此处不建议全局开启该选项,因为有的脚本可能会使用 `git am` 之类的涉及到 `commit` 的命令,如果全局开启的话会导致问题。 + +```shell +git config commit.gpgsign true +``` + +如果提交到 GitHub,前往 [GitHub SSH and GPG keys](https://github.com/settings/keys) 添加公钥。此处添加后,可以直接通过对应 GitHub ID 来获取公钥:`https://github.com/.gpg` + +## PIV + +首先在Web端添加自己的私钥到智能卡,之后前往 [WinCrypt SSH Agent](https://github.com/buptczq/WinCryptSSHAgent) 下载并运行,此时查看 `ssh-agent` 读取到的公钥信息,把输出的公钥信息添加到服务器的 `~/.ssh/authorized_keys` + +```shell +# 设置环境池 +$Env:SSH_AUTH_SOCK="\\.\pipe\openssh-ssh-agent" +# 查看ssh列表 +ssh-add -L +``` + +此时连接 `ssh user@host`,会弹出提示输入 `PIN` 的页面,注意此时输入的是 `PIV Applet PIN`,输入后即可成功连接服务器。 + +```yaml +tips: 可能会出现权限不够的情况,需要禁用Windows服务OpenSSH Authentication Agent +``` + +最后可以把该程序快捷方式添加到启动目录 `%AppData%\Microsoft\Windows\Start Menu\Programs\Startup`,方便直接使用。 diff --git a/backend/content/posts/ffmpeg.md b/backend/content/posts/ffmpeg.md new file mode 100644 index 0000000..36b8692 --- /dev/null +++ b/backend/content/posts/ffmpeg.md @@ -0,0 +1,67 @@ +--- +title: "如何使用FFmpeg处理音视频文件" +description: 本文提供了FFmpeg处理音视频文件的完整指南,包括将单张图片转换为视频、拼接多个视频、设置转场特效等多种操作。 +date: 2022-07-25T14:05:04+08:00 +draft: true +slug: ffmpeg +image: +categories: ffmpeg +tags: ffmpeg +--- + +# `ffmpeg`图片转视频 + +使用单张图片生成5秒视频 + +```bash +# -loop 1 指定开启单帧图片loop +# -t 5 指定loop时长为5秒 +# -i input 指定输入图片文件路径 示例:pic.jpg +# -pix_fmt 指定编码格式为yuv420p +# -y 若输出文件已存在,则强制进行覆盖。 +# ffmpeg会根据输出文件后缀,自动选择编码格式。 +# 也可以使用 -f 指定输出格式 +ffmpeg -loop 1 -t 5 -i .jpg -pix_fmt yuv420p -y output.ts +``` + +# `ffmpeg`拼接视频 + +```bash +# windows +# -i input 指定需要合并的文件,使用concat进行合并.示例:"concat:0.ts|1.ts|2.ts" +# -vcodec 指定视频编码器的参数为copy +# -acodec 指定音频编码器的参数为copy +# -y 若输出文件已存在,则强制进行覆盖。 +ffmpeg -i "concat:0.ts|1.ts" -vcodec copy -acodec copy -y output.ts +``` + +# `ffmpeg`设置转场特效 + +```bash +# Linux +ffmpeg -i v0.mp4 -i v1.mp4 -i v2.mp4 -i v3.mp4 -i v4.mp4 -filter_complex \ +"[0][1:v]xfade=transition=fade:duration=1:offset=3[vfade1]; \ + [vfade1][2:v]xfade=transition=fade:duration=1:offset=10[vfade2]; \ + [vfade2][3:v]xfade=transition=fade:duration=1:offset=21[vfade3]; \ + [vfade3][4:v]xfade=transition=fade:duration=1:offset=25,format=yuv420p; \ + [0:a][1:a]acrossfade=d=1[afade1]; \ + [afade1][2:a]acrossfade=d=1[afade2]; \ + [afade2][3:a]acrossfade=d=1[afade3]; \ + [afade3][4:a]acrossfade=d=1" \ +-movflags +faststart out.mp4 +``` + +| 输入文件 | 输入文件的视频总长 | + | previous xfade `offset` | - | xfade `duration` | `offset` = | +| :------- | :----------------- | :--: | :---------------------- | :--: | :--------------- | :--------- | +| `v0.mp4` | 4 | + | 0 | - | 1 | 3 | +| `v1.mp4` | 8 | + | 3 | - | 1 | 10 | +| `v2.mp4` | 12 | + | 10 | - | 1 | 21 | +| `v3.mp4` | 5 | + | 21 | - | 1 | 25 | + +// 将音频转为单声道 + +``` +ffmpeg -i .\1.mp3 -ac 1 -ar 44100 -ab 16k -vol 50 -f 1s.mp3 +ffmpeg -i one.ts -i 1s.mp3 -map 0:v -map 1:a -c:v copy -shortest -af apad -y one1.ts +``` + diff --git a/backend/content/posts/go-arm.md b/backend/content/posts/go-arm.md new file mode 100644 index 0000000..4526651 --- /dev/null +++ b/backend/content/posts/go-arm.md @@ -0,0 +1,121 @@ +--- +title: "使用arm交叉编译工具并解决GLIBC版本不匹配的问题" +description: 介绍如何使用arm交叉编译工具来编译Go程序,并解决在arm平台上运行时出现GLIBC版本不匹配的问题。 +date: 2022-06-10T15:00:26+08:00 +draft: false +slug: go-arm +image: +categories: + - Go +tags: + - Arm + - Go + - GLIBC +--- + +1. 下载 ARM 交叉编译工具,可以从官方网站下载。比如,可以从如下链接下载 GNU 工具链:[https://developer.arm.com/downloads/-/gnu-a](https://developer.arm.com/downloads/-/gnu-a) + + 示例:https://developer.arm.com/-/media/Files/downloads/gnu-a/10.3-2021.07/binrel/gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-elf.tar.xz + +2. 设置 Go ARM 交叉编译环境变量。具体来说,需要设置以下变量: + +```ruby +$env:GOOS="linux" +$env:GOARCH="arm64" +$env:CGO_ENABLED=1 +$env:CC="D:\arm\gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-linux-gnu\bin\aarch64-none-linux-gnu-gcc.exe" +$env:CXX="D:\arm\gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-linux-gnu\bin\aarch64-none-linux-gnu-g++.exe" +``` + +3. 在 ARM 上运行程序时可能会出现如下错误: + +```bash +./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found (required by ./bupload) +./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.32' not found (required by ./bupload) +./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.33' not found (required by ./bupload) +``` + +这是因为程序需要使用较新版本的 GLIBC 库,而 ARM 上安装的库版本较旧。可以通过以下步骤来解决这个问题: + +4. 查看当前系统中 libc 库所支持的版本: + +```bash +strings /lib/aarch64-linux-gnu/libc.so.6 | grep GLIBC_ +``` + +5. 备份整个 `/lib` 目录和 `/usr/include` 目录,以便稍后还原。 +6. 从 GNU libc 官方网站下载对应版本的 libc 库。例如,可以从如下链接下载 2.35 版本的 libc 库:[http://ftp.gnu.org/gnu/glibc/glibc-2.35.tar.xz](http://ftp.gnu.org/gnu/glibc/glibc-2.35.tar.xz) +7. 解压 libc 库: + +``` +xz -d glibc-2.35.tar.xz +tar xvf glibc-2.35.tar glibc-2.35 +``` + +8. 创建并进入 build 目录: + +```bash +mkdir build +cd build +``` + +9. 配置 libc 库的安装选项: + +```javascript +../configure --prefix=/usr --disable-profile --enable-add-ons --with-headers=/usr/include --with-binutils=/usr/bin +``` + +10. 编译并安装 libc 库: + +```go +make -j4 +make install +``` + +接下来是关于 `make` 报错的部分: + +```yaml +asm/errno.h: No such file or directory +``` + +这个报错是因为 `errno.h` 文件中包含了 `asm/errno.h` 文件,但是找不到这个文件。为了解决这个问题,我们需要创建一个软链接: + +```bash +ln -s /usr/include/asm-generic /usr/include/asm +``` + +然后又出现了另一个报错: + +```bash +/usr/include/aarch64-linux-gnu/asm/sigcontext.h: No such file or directory +``` + +这个问题也可以通过重新安装`linux-libc-dev`后创建软链接来解决: + +```bash +# find / -name sigcontext.h +sudo apt-get install --reinstall linux-libc-dev +ln -s /usr/include/aarch64-linux-gnu/asm/sigcontext.h /usr/include/asm/sigcontext.h +``` + +接下来,还有一个报错: + +```yaml +asm/sve_context.h: No such file or directory +``` + +这个报错是因为最新的 Linux 内核在启用 ARM Scalable Vector Extension (SVE) 后,需要包含 `asm/sve_context.h` 文件。我们需要创建一个软链接来解决这个问题: + +```bash +# find / -name sve_context.h +ln -s /usr/include/aarch64-linux-gnu/asm/sve_context.h /usr/include/asm/sve_context.h +``` + +最后,还需要创建一个软链接: + +```bash +# find / -name byteorder.h +ln -s /usr/include/aarch64-linux-gnu/asm/byteorder.h /usr/include/asm/byteorder.h +``` + +完成以上步骤后,我们再次执行 `make` 命令,就应该可以顺利地编译和安装 glibc 了。 diff --git a/backend/content/posts/go-grpc.md b/backend/content/posts/go-grpc.md new file mode 100644 index 0000000..e1af849 --- /dev/null +++ b/backend/content/posts/go-grpc.md @@ -0,0 +1,173 @@ +--- +title: "Go使用gRPC进行通信" +description: RPC是远程过程调用的简称,是分布式系统中不同节点间流行的通信方式。 +date: 2022-05-26T14:17:33+08:00 +draft: false +slug: go-grpc +image: +categories: + - Go +tags: + - Go + - gRPC +--- + +# 安装`gRPC`和`Protoc` + +## 安装`protobuf` + +```bash +go get -u google.golang.org/protobuf +go get -u google.golang.org/protobuf/proto +go get -u google.golang.org/protobuf/protoc-gen-go +``` + + + +## 安装`Protoc` + +```shell +# 下载二进制文件并添加至环境变量 +https://github.com/protocolbuffers/protobuf/releases +``` + +安装`Protoc`插件`protoc-gen-go` + +```shell +# go install 会自动编译项目并添加至环境变量中 +go install google.golang.org/protobuf/cmd/protoc-gen-go@latest +``` + +```shell +#protoc-gen-go 文档地址 +https://developers.google.com/protocol-buffers/docs/reference/go-generated +``` + +# 创建`proto`文件并定义服务 + +## 新建 `task.proto`文件 + +```shell +touch task.proto +``` + +## 编写`task.proto` + +```protobuf +// 指定proto版本 +syntax = "proto3"; +// 指定包名 +package task; +// 指定输出 go 语言的源码到哪个目录和 包名 +// 主要 目录和包名用 ; 隔开 +// 将在当前目录生成 task.pb.go +// 也可以只填写 "./",会生成的包名会变成 "----" +option go_package = "./;task"; + +// 指定RPC的服务名 +service TaskService { + // 调用 AddTaskCompletion 方法 + rpc AddTaskCompletion(request) returns (response); +} + +// RPC TaskService服务,AddTaskCompletion函数的请求参数,即消息 +message request { + uint32 id = 1;//任务id + string module = 2;//所属模块 + int32 value = 3;//此次完成值 + string guid = 4;//用户id +} +// RPC TaskService服务,TaskService函数的返回值,即消息 +message response{ + +} +``` + +## 使用`Protoc`来生成Go代码 + +```bash +protoc --go_out=. --go-grpc_out=. <要进行生成代码的文件>.proto +# example +protoc --go_out=. --go-grpc_out=. .\task.proto +``` + +这样生成会生成两个`.go`文件,一个是对应消息`task.pb.go`,一个对应服务接口`task_grpc.pb.go`。 + +在`task_grpc.pb.go`中,在我们定义的服务接口中,多增加了一个私有的接口方法: +`mustEmbedUnimplementedTaskServiceServer()` + +# 使用`Go`监听`gRPC`服务端及客户端 + +## 监听服务端 + +并有生成的一个`UnimplementedTaskServiceServer`结构体来实现了所有的服务接口。因此,在我们自己实现的服务类中,需要继承这个结构体,如: + +```go +// 用于实现grpc服务 TaskServiceServer 接口 +type TaskServiceImpl struct { + // 需要继承结构体 UnimplementedServiceServer 或mustEmbedUnimplementedTaskServiceServer + task.mustEmbedUnimplementedTaskServiceServer() +} + +func main() { + // 创建Grpc服务 + // 创建tcp连接 + listener, err := net.Listen("tcp", ":8082") + if err != nil { + fmt.Println(err) + return + } + // 创建grpc服务 + grpcServer := grpc.NewServer() + // 此函数在task.pb.go中,自动生成 + task.RegisterTaskServiceServer(grpcServer, &TaskServiceImpl{}) + // 在grpc服务上注册反射服务 + reflection.Register(grpcServer) + // 启动grpc服务 + err = grpcServer.Serve(listener) + if err != nil { + fmt.Println(err) + return + } + +} + +func (s *TaskServiceImpl) AddTaskCompletion(ctx context.Context, in *task.Request) (*task.Response, error) { + fmt.Println("收到一个Grpc 请求, 请求参数为", in.Guid) + r := &task.Response{ + } + return r, nil +} + +``` + +然后在`TaskService`上实现我们的服务接口。 + + +## 客户端 + +```go + conn, err := grpc.Dial("127.0.0.1:8082", grpc.WithInsecure()) + if err != nil { + panic(err) + } + defer conn.Close() + // 创建grpc客户端 + client := task.NewTaskServiceClient(conn) + // 创建请求 + req := &task.Request{ + Id: 1, + Module: "test", + Value: 3, + Guid: "test", + } + // 调用rpc TaskService AddTaskCompletion函数 + response, err := client.AddTaskCompletion(context.Background(), req) + if err != nil { + log.Println(err) + return + } + log.Println(response) +``` + +[本文参考](https://www.cnblogs.com/whuanle/p/14588031.html) diff --git a/backend/content/posts/go-xml.md b/backend/content/posts/go-xml.md new file mode 100644 index 0000000..bcdbdc4 --- /dev/null +++ b/backend/content/posts/go-xml.md @@ -0,0 +1,98 @@ +--- +title: "Go语言解析Xml" +slug: "go-xml" +date: 2022-05-20T14:38:05+08:00 +draft: false +description: "使用Go简简单单的解析Xml!" +tags: + - Go + - Xml +categories: + - Go +--- + +# 开始之前 + +```go +import "encoding/xml" +``` + +## 简单的`Xml`解析 + +### 1.假设我们解析的`Xml`内容如下: + +```xml + + + +``` + + + +### 2.接着我们构造对应的结构体 + +```go +type Feed struct { + XMLName xml.Name `xml:"feed"` + Person struct{ + Name string `xml:"name"` + Id string `xml:"id"` + Age int `xml:"age"` + } `xml:"person"` +} +``` + +### 3.对`Xml`数据进行反序列化 + +```go +var feed Feed + +// 读取Xml文件,并返回字节流 +content,err := ioutil.ReadFile(XmlFilename) +if err != nil { + log.Fatal(err) +} + +// 将读取到的内容反序列化到feed +xml.Unmarshal(content,&feed) +``` + +## 带有命名空间的`Xml`解析 + +部分`xml`文件会带有`命名空间`(`Namespace`),也就是冒号左侧的内容,此时我们需要在`go`结构体的`tag` 中加入`命名空间`。 + +### 1.带有命名空间(Namespace)的`Xml`文件 + +```xml + + +XXXXXXX + + + +``` + +### 2.针对命名空间构造结构体 + +```go +type Feed struct { + XMLName xml.Name `xml:"feed"` // 指定最外层的标签为feed + VideoId string `xml:"http://www.youtube.com/xml/schemas/2015 videoId"` + Community string `xml:"http://search.yahoo.com/mrss/ community"` +} +``` + +### 3.对`Xml`数据进行反序列化 + +```go +var feed Feed + +// 读取Xml文件,并返回字节流 +content,err := ioutil.ReadFile(XmlFilename) +if err != nil { + log.Fatal(err) +} + +// 将读取到的内容反序列化到feed +xml.Unmarshal(content,&feed) +``` diff --git a/backend/content/posts/hugo.md b/backend/content/posts/hugo.md new file mode 100644 index 0000000..678d5da --- /dev/null +++ b/backend/content/posts/hugo.md @@ -0,0 +1,36 @@ +--- +title: "Hugo使用指南!" +slug: "hugo" +draft: false +date: 2022-05-20T10:23:53+08:00 +description: "快速上手hugo!" +tags: + - Go + - Hugo +categories: + - Go +--- +查看Hugo版本号 + +```bash +hugo version +``` + +新建一个Hugo页面 + +``` +hugo new site +``` + +设置主题 + +```bash +cd +git init + +# 设置为 Stack主题 +git clone https://github.com/CaiJimmy/hugo-theme-stack/ themes/hugo-theme-stack +git submodule add https://github.com/CaiJimmy/hugo-theme-stack/ themes/hugo-theme-stack +``` + +部署Hugo到github diff --git a/backend/content/posts/linux-dhcp.md b/backend/content/posts/linux-dhcp.md new file mode 100644 index 0000000..b4804df --- /dev/null +++ b/backend/content/posts/linux-dhcp.md @@ -0,0 +1,67 @@ +--- +title: "Linux部署DHCP服务" +description: Debian下使用docker镜像部署DHCP服务 +date: 2022-05-23T11:11:40+08:00 +draft: false +slug: linux-dhcp +image: +categories: Linux +tags: + - Linux + - DHCP +--- + +拉取`networkboot/dhcpd`镜像 + +```shell +docker pull networkboot/dhcpd +``` + +新建`data/dhcpd.conf`文件 + +```shell +touch /data/dhcpd.conf +``` + +修改`data/dhcpd.conf`文件 + +``` +subnet 204.254.239.0 netmask 255.255.255.224 { +option subnet-mask 255.255.0.0; +option domain-name "cname.nmslwsnd.com"; +option domain-name-servers 8.8.8.8; +range 204.254.239.10 204.254.239.30; +} +``` + +修改`/etc/network/interfaces` + +``` +# The loopback network interface (always required) +auto lo +iface lo inet loopback + +# Get our IP address from any DHCP server +auto dhcp +iface dhcp inet static +address 204.254.239.0 +netmask 255.255.255.224 + +``` + + + +获取帮助命令 + +```shell +docker run -it --rm networkboot/dhcpd man dhcpd.conf +``` + +运行`DHCP`服务 + +```shell +docker run -it --rm --init --net host -v "/data":/data networkboot/dhcpd <网卡名称> +# 示例 +docker run -it --rm --init --net host -v "/data":/data networkboot/dhcpd dhcp +``` + diff --git a/backend/content/posts/linux-shell.md b/backend/content/posts/linux-shell.md new file mode 100644 index 0000000..17f74e9 --- /dev/null +++ b/backend/content/posts/linux-shell.md @@ -0,0 +1,36 @@ +--- +title: "Linux Shell" +description: +date: 2022-05-21T10:02:09+08:00 +draft: false +Hidden: true +slug: linux-shell +image: +categories: + Linux +tag: + Linux + Shell +--- + +Linux守护进程:no_good: + +```bash +#!/bin/bash +# nohup.sh +while true +do + # -f 后跟进程名,判断进程是否正在运行 + if [ `pgrep -f | wc -l` -eq 0 ];then + echo "进程已终止" + push + # /dev/null 无输出日志 + nohup ./ > /dev/null 2>&1 & + else + echo "进程正在运行" + fi + # 每隔1分钟检查一次 + sleep 1m +done +``` + diff --git a/backend/content/posts/linux.md b/backend/content/posts/linux.md new file mode 100644 index 0000000..cd92c78 --- /dev/null +++ b/backend/content/posts/linux.md @@ -0,0 +1,65 @@ +--- +title: "Linux" +description: +date: 2022-09-08T15:19:00+08:00 +draft: true +slug: linux +image: +categories: + - Linux +tags: + - Linux +--- + +```bash +# 使用cd 进入到上一个目录 +cd - +``` + +复制和粘贴 + +```bash +ctrl + shift + c +ctrl + shift + v +``` + + + +快速移动 + +```bash +# 移动到行首 +ctrl + a +# 移动到行尾 +ctrl + e +``` + +快速删除 + +```bash +# 删除光标之前的内容 +ctrl + u +# 删除光标之后的内容 +ctrl + k +# 恢复之前删除的内容 +ctrl + y +``` + +不适用cat + +``` +使用less 查看 顶部的文件 +less filename +``` + +使用alt+backspace删除,以单词为单位 + +``` + tcpdump host 1.1.1.1 +``` + +``` +# 并行执行命令 Parallel +find . -type f -name '*.html' -print | parallel gzip +``` + diff --git a/backend/content/posts/loco-rs-framework.md b/backend/content/posts/loco-rs-framework.md deleted file mode 100644 index 89fc686..0000000 --- a/backend/content/posts/loco-rs-framework.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Loco.rs Backend Framework -slug: loco-rs-framework -description: An introduction to Loco.rs, the Rails-inspired web framework for Rust. -category: tech -post_type: article -pinned: false -published: true -tags: - - rust - - loco-rs - - backend - - api ---- - -# Introduction to Loco.rs - -Loco.rs is a web and API framework for Rust inspired by Rails. - -## Features - -- MVC Architecture -- SeaORM Integration -- Background Jobs -- Authentication -- CLI Generator - -## Quick Start - -```bash -cargo install loco -loco new myapp -cd myapp -cargo loco start -``` - -## Why Loco.rs? - -- Opinionated but flexible -- Production-ready defaults -- Excellent documentation -- Active community - -Perfect for building APIs and web applications in Rust. diff --git a/backend/content/posts/mysql.md b/backend/content/posts/mysql.md new file mode 100644 index 0000000..a85fae2 --- /dev/null +++ b/backend/content/posts/mysql.md @@ -0,0 +1,569 @@ +--- +title: "mysql个人常用命令及操作" +description: +date: 2021-09-21T16:13:24+08:00 +draft: true +slug: mysql +image: +categories: + - Database +tags: + - Linux + - Mysql + - Sql +--- + +启动`mysql` + +```bash +sudo service mysql start +``` + +使用`root`账户登录`mysql` + +```bash +sudo mysql -u root +``` + +查看数据库信息 + +```mysql +show databases; +``` + +新增数据库 + +```mysql +create database <新增的数据库名>; +# 示例,新增一个名为gradesystem的数据库 +create database gradesystem; + +``` + +切换数据库 + +```mysql +use <切换的数据库名>; +# 示例,切换至gradesystem数据库 +use gradesystem; +``` + +查看数据库中的表 + +```mysql +# 查看数据库中所有的表 +show tables; +``` + +新增表 + +```mysql +# MySQL不区分大小写 +CREATE TABLE student( + sid int NOT NULL AUTO_INCREMENT, + sname varchar(20) NOT NULL, + gender varchar(10) NOT NULL, + PRIMARY KEY(sid) + ); +# 新增一个表名为学生的表。 +# AUTO_INCREMENT, 自动地创建主键字段的值。 +# PRIMARY KEY(sid) 设置主键为sid +CREATE TABLE course( + cid int not null auto_increment, + cname varchar(20) not null, + primary key(cid) +); +# 新增一个表名为课程的表。 +# primary key(cid) 设置主键为cid + +CREATE TABLE mark( + mid int not null auto_increment, + sid int not null, + cid int not null, + score int not null, + primary key(mid), + foreign key(sid) references student(sid), + foreign key(cid) references course(cid) +); +# 新增一个表明为mark的表 +# primary key(cid) 设置主键为cid +# foreign 设置外键为sid +# foreign 设置外键为cid + +insert into student values(1,'Tom','male'),(2,'Jack','male'),(3,'Rose','female'); +# 向student表插入数据,sid为1,sname为'Tom',gender为'male' + +insert into course values(1,'math'),(2,'physics'),(3,'chemistry'); +# 向course表插入数据,sid为1,cname为'math' + +insert into mark values(1,1,1,80); +# 向mark表插入数据,mid为1,sid为1,cid为1,score为80 +``` + +### 向数据库插入数据 + +```mysql + source <数据库文件所在目录> + + +``` + +## SELECT语句查询 + + SELECT 语句的基本格式为: + +```bash +SELECT 要查询的列名 FROM 表名字 WHERE 限制条件; +``` + +```mysql +select name,age from employee; +# 查看employee的name列和age列 + +select name,age from employee where age > 25; +# 筛选出age 大于25的结果 + +select name,age,phone from employee where name = 'Mary'; +# 筛选出name为'Mary'的name,age,phone + +select name,age,phone from employee where age < 25 or age >30; +# 筛选出age小于30或大于25的name,age,phone + +select name,age,phone from employee where age > 25 and age < 30; +# 筛选出age大于25且小于30的name,age,phone + +select name,age,phone from employee where age between 25 and 30; +# 筛选出包含25和30的,name,age,phone + +select name,age,phone,in_dpt from employee where in_dpt in('dpt3','dpt4'); +# 筛选出在dpt3或dpt4里面的name,age,phone,in_dpt + +select name,age,phone,in_dpt from employee where in_dpt not in('dpt1','dpt3'); +# 筛选出不在dpt1和dpt3的name,age,phone,in_dpt + + +``` + +## 通配符 + +关键字 **LIKE** 可用于实现模糊查询,常见于搜索功能中。 + +和 LIKE 联用的通常还有通配符,代表未知字符。SQL 中的通配符是 `_` 和 `%` 。其中 `_` 代表一个**未指定**字符,`%` 代表**不定个**未指定字符 + +```mysql +select name,age,phone from employee where phone like '1101__'; +# 筛选出1101开头的六位数字的name,age,phone + +select name,age,phone from employee where name like 'J%'; +# 筛选出name位J开头的人的name,age,phone +``` + +## 排序 + + 为了使查询结果看起来更顺眼,我们可能需要对结果按某一列来排序,这就要用到 **ORDER BY** 排序关键词。默认情况下,**ORDER BY** 的结果是**升序**排列,而使用关键词 **ASC** 和 **DESC** 可指定**升序**或**降序**排序。 比如,我们**按 salary 降序排列**,SQL 语句为 + +```mysql +select name,age,salary,phone from employee order by salary desc; +# salary列按降序排列 +select name,age,salary,phone from employee order by salary; +# 不加 DESC 或 ASC 将默认按照升序排列。 +``` + +## SQL 内置函数和计算 + +置函数,这些函数都对 SELECT 的结果做操作: + +| 函数名: | COUNT | SUM | AVG | MAX | MIN | +| -------- | ----- | ---- | -------- | ------ | ------ | +| 作用: | 计数 | 求和 | 求平均值 | 最大值 | 最小值 | + +> 其中 COUNT 函数可用于任何数据类型(因为它只是计数),而 SUM 、AVG 函数都只能对数字类数据类型做计算,MAX 和 MIN 可用于数值、字符串或是日期时间数据类型。 + + + +```mysql +select max(salary) as max_salary,min(salary) from employee; +# 使用as关键字可以给值重命名, +``` + +## 连接查询 + + 在处理多个表时,子查询只有在结果来自一个表时才有用。但如果需要显示两个表或多个表中的数据,这时就必须使用连接 **(join)** 操作。 连接的基本思想是把两个或多个表当作一个新的表来操作,如下: + +```mysql +select id,name,people_num from employee,department where employee.in_dpt = department.dpt_name order by id; +# 这条语句查询出的是,各员工所在部门的人数,其中员工的 id 和 name 来自 employee 表,people_num 来自 department 表: + +select id,name,people_num from employee join department on employee.in_dpt = department.dpt_name order by id; +# 另一个连接语句格式是使用 JOIN ON 语法,刚才的语句等同于以上语句 +``` + +## 删除数据库 + +```mysql +drop database test_01; +# 删除名为test_01的数据库; +``` + +### 修改表 + +重命名一张表的语句有多种形式,以下 3 种格式效果是一样的: + +```sql +RENAME TABLE 原名 TO 新名字; + +ALTER TABLE 原名 RENAME 新名; + +ALTER TABLE 原名 RENAME TO 新名; +``` + +进入数据库 mysql_shiyan : + +```mysql +use mysql_shiyan +``` + +使用命令尝试修改 `table_1` 的名字为 `table_2` : + +```mysql +RENAME TABLE table_1 TO table_2; +``` + +删除一张表的语句,类似于刚才用过的删除数据库的语句,格式是这样的: + +```sql +DROP TABLE 表名字; +``` + +比如我们把 `table_2` 表删除: + +```mysql +DROP TABLE table_2; +``` + +#### 增加一列 + +在表中增加一列的语句格式为: + +```sql +ALTER TABLE 表名字 ADD COLUMN 列名字 数据类型 约束; +或: +ALTER TABLE 表名字 ADD 列名字 数据类型 约束; +``` + +现在 employee 表中有 `id、name、age、salary、phone、in_dpt` 这 6 个列,我们尝试加入 `height` (身高)一个列并指定 DEFAULT 约束: + +```mysql +ALTER TABLE employee ADD height INT(4) DEFAULT 170; +``` + +可以发现:新增加的列,被默认放置在这张表的最右边。如果要把增加的列插入在指定位置,则需要在语句的最后使用 AFTER 关键词(**“AFTER 列 1” 表示新增的列被放置在 “列 1” 的后面**)。 + +> 提醒:语句中的 INT(4) 不是表示整数的字节数,而是表示该值的显示宽度,如果设置填充字符为 0,则 170 显示为 0170 + +比如我们新增一列 `weight`(体重) 放置在 `age`(年龄) 的后面: + +```mysql +ALTER TABLE employee ADD weight INT(4) DEFAULT 120 AFTER age; +``` + + + +上面的效果是把新增的列加在某位置的后面,如果想放在第一列的位置,则使用 `FIRST` 关键词,如语句: + +```sql +ALTER TABLE employee ADD test INT(10) DEFAULT 11 FIRST; +``` + +#### 删除一列 + +删除表中的一列和刚才使用的新增一列的语句格式十分相似,只是把关键词 `ADD` 改为 `DROP` ,语句后面不需要有数据类型、约束或位置信息。具体语句格式: + +```sql +ALTER TABLE 表名字 DROP COLUMN 列名字; + +或: ALTER TABLE 表名字 DROP 列名字; +``` + +我们把刚才新增的 `test` 删除: + +```sql +ALTER TABLE employee DROP test; +``` + +#### 重命名一列 + +这条语句其实不只可用于重命名一列,准确地说,它是对一个列做修改(CHANGE) : + +```sql +ALTER TABLE 表名字 CHANGE 原列名 新列名 数据类型 约束; +``` + +> **注意:这条重命名语句后面的 “数据类型” 不能省略,否则重命名失败。** + +当**原列名**和**新列名**相同的时候,指定新的**数据类型**或**约束**,就可以用于修改数据类型或约束。需要注意的是,修改数据类型可能会导致数据丢失,所以要慎重使用。 + +我们用这条语句将 “height” 一列重命名为汉语拼音 “shengao” ,效果如下: + +```mysql +ALTER TABLE employee CHANGE height shengao INT(4) DEFAULT 170; +``` + +#### 改变数据类型 + +要修改一列的数据类型,除了使用刚才的 **CHANGE** 语句外,还可以用这样的 **MODIFY** 语句: + +```sql +ALTER TABLE 表名字 MODIFY 列名字 新数据类型; +``` + +再次提醒,修改数据类型必须小心,因为这可能会导致数据丢失。在尝试修改数据类型之前,请慎重考虑。 + +#### 修改表中某个值 + +大多数时候我们需要做修改的不会是整个数据库或整张表,而是表中的某一个或几个数据,这就需要我们用下面这条命令达到精确的修改: + +```sql +UPDATE 表名字 SET 列1=值1,列2=值2 WHERE 条件; +``` + +比如,我们要把 Tom 的 age 改为 21,salary 改为 3000: + +```mysql +UPDATE employee SET age=21,salary=3000 WHERE name='Tom'; +``` + +> **注意:一定要有 WHERE 条件,否则会出现你不想看到的后果** + +#### 删除一行记录 + +删除表中的一行数据,也必须加上 WHERE 条件,否则整列的数据都会被删除。删除语句: + +```sql +DELETE FROM 表名字 WHERE 条件; +``` + +我们尝试把 Tom 的数据删除: + +```mysql +DELETE FROM employee WHERE name='Tom'; +``` + +#### 索引 + +索引是一种与表有关的结构,它的作用相当于书的目录,可以根据目录中的页码快速找到所需的内容。 + +当表中有大量记录时,若要对表进行查询,没有索引的情况是全表搜索:将所有记录一一取出,和查询条件进行对比,然后返回满足条件的记录。这样做会执行大量磁盘 I/O 操作,并花费大量数据库系统时间。 + +而如果在表中已建立索引,在索引中找到符合查询条件的索引值,通过索引值就可以快速找到表中的数据,可以**大大加快查询速度**。 + +对一张表中的某个列建立索引,有以下两种语句格式: + +```sql +ALTER TABLE 表名字 ADD INDEX 索引名 (列名); + +CREATE INDEX 索引名 ON 表名字 (列名); +``` + +我们用这两种语句分别建立索引: + +```sql +ALTER TABLE employee ADD INDEX idx_id (id); #在employee表的id列上建立名为idx_id的索引 + +CREATE INDEX idx_name ON employee (name); #在employee表的name列上建立名为idx_name的索引 +``` + +索引的效果是加快查询速度,当表中数据不够多的时候是感受不出它的效果的。这里我们使用命令 **SHOW INDEX FROM 表名字;** 查看刚才新建的索引: + +![01](https://doc.shiyanlou.com/MySQL/sql-06-01.png) + +在使用 SELECT 语句查询的时候,语句中 WHERE 里面的条件,会**自动判断有没有可用的索引**。 + +比如有一个用户表,它拥有用户名(username)和个人签名(note)两个字段。其中用户名具有唯一性,并且格式具有较强的限制,我们给用户名加上一个唯一索引;个性签名格式多变,而且允许不同用户使用重复的签名,不加任何索引。 + +这时候,如果你要查找某一用户,使用语句 `select * from user where username=?` 和 `select * from user where note=?` 性能是有很大差距的,对**建立了索引的用户名**进行条件查询会比**没有索引的个性签名**条件查询快几倍,在数据量大的时候,这个差距只会更大。 + +一些字段不适合创建索引,比如性别,这个字段存在大量的重复记录无法享受索引带来的速度加成,甚至会拖累数据库,导致数据冗余和额外的 CPU 开销。 + +## 视图 + + + +视图是从一个或多个表中导出来的表,是一种**虚拟存在的表**。它就像一个窗口,通过这个窗口可以看到系统专门提供的数据,这样,用户可以不用看到整个数据库中的数据,而只关心对自己有用的数据。 + +注意理解视图是虚拟的表: + +- 数据库中只存放了视图的定义,而没有存放视图中的数据,这些数据存放在原来的表中; +- 使用视图查询数据时,数据库系统会从原来的表中取出对应的数据; +- 视图中的数据依赖于原来表中的数据,一旦表中数据发生改变,显示在视图中的数据也会发生改变; +- 在使用视图的时候,可以把它当作一张表。 + +创建视图的语句格式为: + +```sql +CREATE VIEW 视图名(列a,列b,列c) AS SELECT 列1,列2,列3 FROM 表名字; +``` + +可见创建视图的语句,后半句是一个 SELECT 查询语句,所以**视图也可以建立在多张表上**,只需在 SELECT 语句中使用**子查询**或**连接查询**,这些在之前的实验已经进行过。 + +现在我们创建一个简单的视图,名为 **v_emp**,包含**v_name**,**v_age**,**v_phone**三个列: + +```sql +CREATE VIEW v_emp (v_name,v_age,v_phone) AS SELECT name,age,phone FROM employee; +``` + +![02](https://doc.shiyanlou.com/MySQL/sql-06-02.png) + +## 导出 + + + +导出与导入是相反的过程,是把数据库某个表中的数据保存到一个文件之中。导出语句基本格式为: + +```sql +SELECT 列1,列2 INTO OUTFILE '文件路径和文件名' FROM 表名字; +``` + +**注意:语句中 “文件路径” 之下不能已经有同名文件。** + +现在我们把整个 employee 表的数据导出到 /var/lib/mysql-files/ 目录下,导出文件命名为 **out.txt** 具体语句为: + +```sql +SELECT * INTO OUTFILE '/var/lib/mysql-files/out.txt' FROM employee; +``` + +用 gedit 可以查看导出文件 `/var/lib/mysql-files/out.txt` 的内容: + +> 也可以使用 `sudo cat /var/lib/mysql-files/out.txt` 命令查看。 + +## 备份 + + + +数据库中的数据十分重要,出于安全性考虑,在数据库的使用中,应该注意使用备份功能。 + +> 备份与导出的区别:导出的文件只是保存数据库中的数据;而备份,则是把数据库的结构,包括数据、约束、索引、视图等全部另存为一个文件。 + +**mysqldump** 是 MySQL 用于备份数据库的实用程序。它主要产生一个 SQL 脚本文件,其中包含从头重新创建数据库所必需的命令 CREATE TABLE INSERT 等。 + +使用 mysqldump 备份的语句: + +```bash +mysqldump -u root 数据库名>备份文件名; #备份整个数据库 + +mysqldump -u root 数据库名 表名字>备份文件名; #备份整个表 +``` + +> mysqldump 是一个备份工具,因此该命令是在终端中执行的,而不是在 mysql 交互环境下 + +我们尝试备份整个数据库 `mysql_shiyan`,将备份文件命名为 `bak.sql`,先 `Ctrl+D` 退出 MySQL 控制台,再打开 Xfce 终端,在终端中输入命令: + +```bash +cd /home/shiyanlou/ +mysqldump -u root mysql_shiyan > bak.sql; +``` + +使用命令 “ls” 可见已经生成备份文件 `bak.sql`: + +![07](https://doc.shiyanlou.com/MySQL/sql-06-07.png) + +> 你可以用 gedit 查看备份文件的内容,可以看见里面不仅保存了数据,还有所备份的数据库的其它信息。 + +## 恢复 + + + +用备份文件恢复数据库,其实我们早就使用过了。在本次实验的开始,我们使用过这样一条命令: + +```bash +source /tmp/SQL6/MySQL-06.sql +``` + +这就是一条恢复语句,它把 MySQL-06.sql 文件中保存的 `mysql_shiyan` 数据库恢复。 + +还有另一种方式恢复数据库,但是在这之前我们先使用命令新建一个**空的数据库 test**: + +```bash +mysql -u root #因为在上一步已经退出了 MySQL,现在需要重新登录 +CREATE DATABASE test; #新建一个名为test的数据库 +``` + +再次 **Ctrl+D** 退出 MySQL,然后输入语句进行恢复,把刚才备份的 **bak.sql** 恢复到 **test** 数据库: + +```bash +mysql -u root test < bak.sql +``` + +我们输入命令查看 test 数据库的表,便可验证是否恢复成功: + +```bash +mysql -u root # 因为在上一步已经退出了 MySQL,现在需要重新登录 +use test # 连接数据库 test + +SHOW TABLES; # 查看 test 数据库的表 +``` + +可以看见原数据库的 4 张表和 1 个视图,现在已经恢复到 test 数据库中: + +![08](https://doc.shiyanlou.com/MySQL/sql-06-08.png) + +再查看 employee 表的恢复情况: + +![09](https://doc.shiyanlou.com/MySQL/sql-06-09.png) + +## Mysql授权 + +1. 登录MySQL: + +```sql +mysql -u root -p +``` + +2. 进入MySQL并查看用户和主机: + +```sql +use mysql; +select host,user from user; +``` + +3. 更新root用户允许远程连接: + +```sql +update user set host='%' where user='root'; +``` + +4. 设置root用户密码: + +```sql +alter user 'root'@'localhost' identified by 'your_password'; +``` + +注意:不要使用临时密码。 + +5. 授权允许远程访问: + +```sql +grant all privileges on *.* to 'root'@'%' identified by 'password'; +``` + +请将命令中的“password”更改为您的MySQL密码。 + +6. 刷新授权: + +```sql +flush privileges; +``` + +7. 关闭授权: + +```sql +revoke all on *.* from dba@localhost; +``` + +8. 查看MySQL初始密码: + +```bash +grep "password" /var/log/mysqld.log +``` + +通过以上操作,您的MySQL可以被远程连接并进行管理。请注意在授权和更新用户权限时,应只授权特定的数据库或表格,而不是使用通配符,以提高安全性和减少不必要的权限。在进行远程访问授权时,应只授权特定的IP地址或IP地址段,而不是使用通配符,以减少潜在的安全威胁。同时,建议使用强密码,并定期更换密码以提高安全性。 diff --git a/backend/content/posts/redis.md b/backend/content/posts/redis.md new file mode 100644 index 0000000..058e75d --- /dev/null +++ b/backend/content/posts/redis.md @@ -0,0 +1,119 @@ +--- +title: "Redis 安装与常用命令整理" +slug: redis +description: "文章介绍了 Redis 在 Debian 下的安装方法、Windows 图形客户端的安装方式,以及监听端口修改、BitMap、消息队列、LREM 和 Pipeline 等常用操作示例。" +category: "数据库" +post_type: "article" +pinned: false +published: true +tags: + - "Redis安装" + - "Debian" + - "BitMap" + - "消息队列" + - "Pipeline" + - "go-redis" +--- + +# 安装`Redis` + +## `Debian`下安装`Redis`服务端 + +```bash +curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg + +echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list + +sudo apt-get update +sudo apt-get install redis +``` + +## `Windows`下安装`Redis` 第三方`GUI`客户端 + +Redis (GUI)管理客户端 + +```bash +winget install qishibo.AnotherRedisDesktopManager +``` + +## `Redis`修改监听端口 + +```bash +vim /etc/redis/redis.conf +``` + +# `Redis`常用命令 + +## `bitMap` + +使用`BitMap`实现签到,`setbit key offset value,` `key`做为时间,`offset`做为用户`id` ,`value`做为签到状态 + +```shell +# 示例 +setbit key offset value key +# 设置用户10086在2022/04/21进行签到 +setbit check_in_2022_04_21 10086 1 +# 获取用户10086是否在2022/04/21签到 +getbit check_in_2022_04_21 10086 +# bitcount 获取20220421签到的用户数量 +# 可选 start和end参数 +# start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:比如 -1 表示最后一个位,而 -2 表示倒数第二个位 +BITCOUNT 20220421 +# BITOP 对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上 + +# operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种: + +# BITOP AND destkey key [key ...] ,对一个或多个 key 求逻辑并,并将结果保存到 destkey 。 + +# BITOP OR destkey key [key ...] ,对一个或多个 key 求逻辑或,并将结果保存到 destkey 。 + +# BITOP XOR destkey key [key ...] ,对一个或多个 key 求逻辑异或,并将结果保存到 destkey 。 + +# BITOP NOT destkey key ,对给定 key 求逻辑非,并将结果保存到 destkey 。 + +# 除了 NOT 操作之外,其他操作都可以接受一个或多个 key 作为输入。 + +BITOP AND and-result 20220421 20220420 +GETBIT and-result + +``` + +## `Redis` 消息队列 + +``` +# LPUSH key value, Lpush用于生产并添加消息 +# LPOP key,用于取出消息 +``` + +## `Lrem` + +```shell +# count > 0 : 从表头开始向表尾搜索,移除与 VALUE 相等的元素,数量为 COUNT 。 +# count < 0 : 从表尾开始向表头搜索,移除与 VALUE 相等的元素,数量为 COUNT 的绝对值。 +# count = 0 : 移除表中所有与 VALUE 相等的值。 +LREM key count VALUE +``` + +## `Pipeline` + +`Redis` 使用的是客户端-服务器(`CS`)模型和请求/响应协议的 TCP 服务器。这意味着通常情况下一个请求会遵循以下步骤: + +客户端向服务端发送一个查询请求,并监听 Socket 返回,通常是以阻塞模式,等待服务端响应。 +服务端处理命令,并将结果返回给客户端。 +管道(`pipeline`)可以一次性发送多条命令并在执行完后一次性将结果返回,pipeline 通过减少客户端与 redis 的通信次数来实现降低往返延时时间,而且 `Pipeline` 实现的原理是队列,而队列的原理是时先进先出,这样就保证数据的顺序性。 + +通俗点:`pipeline`就是把一组命令进行打包,然后一次性通过网络发送到Redis。同时将执行的结果批量的返回回来 + +```go +// 使用 go-redis + p := Client.Pipeline() + for _, v := range val { + p.LRem("user:watched:"+guid, 0, v) + } +// p.Exec()执行pipeline 请求 + p.Exec() +``` + + + +[本文参考](https://blog.csdn.net/mumuwang1234/article/details/118603697) diff --git a/backend/content/posts/rust-dll.md b/backend/content/posts/rust-dll.md new file mode 100644 index 0000000..47b5a49 --- /dev/null +++ b/backend/content/posts/rust-dll.md @@ -0,0 +1,169 @@ +--- +title: "手把手教你用Rust进行Dll注入" +description: 我是一个懒惰的男孩,我甚至懒的不想按键盘上的按键和挪动鼠标.可是我还是想玩游戏,该怎么做呢?通过 google 了解到我可以通过将我自己编写的dll文件注入到目标程序内,来实现这个事情. +date: 2022-09-17T15:10:26+08:00 +draft: false +slug: rust-dll +image: +categories: + - Rust +tags: + - Rust + - Dll +--- + +# 前言 + +我是一个懒惰的男孩,我甚至懒的不想按键盘上的按键和挪动鼠标.可是我还是想玩游戏,该怎么做呢? + +通过google了解到我可以通过将我自己编写的 `dll` 文件注入到目标程序内,来实现这个事情. + +将大象放在冰箱里需要几步? + +答案是三步。 + +# `snes9x` 模拟器 `Dll` 注入实战 + +## 一、现在我们需要进行第一步,生成 `Dll` 文件 + +准确说是我们需要生成符合 `C` 标准的 `dll` 文件,如果你使用 `go` 语言,直接使用 `Cgo` 与 `C` 进行互动,即可生成符合 `C` 标准的 `dll` . + +但是很明显,我要用 `Rust` 来做这件事。 + +由于 `Rust` 拥有出色的所有权机制,和其他语言的交互会导致 `Rust` 失去这个特性,所以这一块是属于 `Unsafe` 区域的。 + +`Rust` 默认生成的 `Dll` 是提供给 `Rust` 语言来调用的,而非C系语言的 `dll`. + +我们现在来生成 `C` 系语言的 `Dll` 吧。 + +### 1.新建项目 `lib` 目录 `lib` 目录主要作为库文件以方便其他开发者调用 + +```bash +# 新建库项目 +Cargo new --lib +Cargo new --lib joy +``` + +### 2.修改 `Cargo.toml` 文件 增加 `bin` 区域 + +```toml +[package] +name = "joy" +version = "0.1.0" +edition = "2021" + +[lib] +name = "joy" +path = "src/lib.rs" +crate-type = ["cdylib"] + +[[bin]] +name = "joyrun" +path = "src/main.rs" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +``` + +```bash +# 为项目导入依赖ctor来生成符合c标准的dll +cargo add ctor +``` + +### 3.修改 `lib.rs` 使用 `ctor` + +```rust +// lib.rs +#[ctor::ctor] +fn ctor() { + println!("我是一个dll") +} +``` + +#### 4.编译项目生成 `joy.dll` 以及 `joyrun.exe` + +```bash +cargo build +``` + +现在我们有了我们自己的 `dll` 文件,该如何将他注入到目标的进程呢? + +## 二、使用 `dll-syringe` 进行dll注入 + +``` +cargo add dll-syringe +``` + +### 1.修改main.rs 将刚刚编写的dll注入到目标应用 + +```rust +// main.rs +use dll_syringe::{Syringe, process::OwnedProcess}; + +fn main() { + // 通过进程名找到目标进程 + let target_process = OwnedProcess::find_first_by_name("snes9x").unwrap(); + + // 新建一个注入器 + let syringe = Syringe::for_process(target_process); + + // 将我们刚刚编写的dll加载进去 + let injected_payload = syringe.inject("joy.dll").unwrap(); + + // do something else + + // 将我们刚刚注入的dll从目标程序内移除 + syringe.eject(injected_payload).unwrap(); +} +``` + +### 2.运行项目 + +```shell +# 运行项目 +cargo run +``` + +此时你可能会遇到一个新问题,我的`dll`已经加载进目标程序了,为什么没有打印 "我是一个dll" + +### 3.解决控制台无输出问题 + +这是由于目标程序没有控制台,所以我们没有看到 `dll` 的输出,接下来让我们来获取 `dll` 的输出。 + +此时我们可以使用 `TCP` 交互的方式或采用 `OutputDebugStringA function (debugapi.h)` 来进行打印 + +`OutputDebugStringA` ,需要额外开启`features` `Win32_System_Diagnostics_Debug` + +```rust +// Rust Unsafe fn +// windows::Win32::System::Diagnostics::Debug::OutputDebugStringA +pub unsafe fn OutputDebugStringA<'a, P0>(lpoutputstring: P0) +where + P0: Into, +// Required features: "Win32_System_Diagnostics_Debug" +``` + +采用 `Tcp` 通信交互 + +```rust +// 在lib.rs 新建tcp客户端 +let stream = TcpStream::connect("127.0.0.1:7331").unwrap(); +``` + +```rust + // 在main.rs 新建tcp服务端 + let (mut stream, addr) = listener.accept()?; + info!(%addr,"Accepted!"); + let mut buf = vec![0u8; 1024]; + let mut stdout = std::io::stdout(); + while let Ok(n) = stream.read(&mut buf[..]) { + if n == 0 { + break; + } + stdout.write_all(&buf[..n])? + } +``` + +```shell +# 运行项目 +cargo run +# 运行之后,大功告成,成功在Tcp服务端看到了,客户端对我们发起了请求。 +``` diff --git a/backend/content/posts/rust-programming-tips.md b/backend/content/posts/rust-programming-tips.md deleted file mode 100644 index e51f1f1..0000000 --- a/backend/content/posts/rust-programming-tips.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Rust Programming Tips -slug: rust-programming-tips -description: Essential tips for Rust developers including ownership, pattern matching, and error handling. -category: tech -post_type: article -pinned: false -published: true -tags: - - rust - - programming - - tips ---- - -# Rust Programming Tips - -Here are some essential tips for Rust developers: - -## 1. Ownership and Borrowing - -Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time. - -## 2. Pattern Matching - -Use `match` expressions for exhaustive pattern matching: - -```rust -match result { - Ok(value) => println!("Success: {}", value), - Err(e) => println!("Error: {}", e), -} -``` - -## 3. Error Handling - -Use `Result` and `Option` types effectively with the `?` operator. - -Happy coding! diff --git a/backend/content/posts/rust-serde.md b/backend/content/posts/rust-serde.md new file mode 100644 index 0000000..0a6d8a0 --- /dev/null +++ b/backend/content/posts/rust-serde.md @@ -0,0 +1,96 @@ +--- +title: "Rust使用Serde进行序列化及反序列化" +description: 这篇文章将介绍如何在Rust编程语言中使用Serde库进行序列化和反序列化操作。Serde是一个广泛使用的序列化和反序列化库,能够支持JSON、BSON、CBOR、MessagePack和YAML等常见数据格式。 +date: 2022-07-25T14:02:22+08:00 +draft: false +slug: rust-serde +image: +categories: + - Rust +tags: + - Rust + - Xml +--- + +# 开始之前 + +```toml +# 在Cargo.toml 新增以下依赖 +[dependencies] +serde = { version = "1.0.140",features = ["derive"] } +serde_json = "1.0.82" +serde_yaml = "0.8" +serde_urlencoded = "0.7.1" +# 使用yaserde解析xml +yaserde = "0.8.0" +yaserde_derive = "0.8.0" +``` + +## `Serde`通用规则(`json`,`yaml`,`xml`) + +### 1.使用`Serde`宏通过具体结构实现序列化及反序列化 + +```rust +use serde::{Deserialize, Serialize}; +// 为结构体实现 Serialize(序列化)属性和Deserialize(反序列化) +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Person { + // 将该字段名称修改为lastname + #[serde(rename = "lastname")] + name: String, + // 反序列化及序列化时忽略该字段(nickname) + #[serde(skip)] + nickname: String, + // 分别设置序列化及反序列化时输出的字段名称 + #[serde(rename(serialize = "serialize_id", deserialize = "derialize_id")) + id: i32, + // 为age设置默认值 + #[serde(default)] + age: i32, + +} +``` + +### 2.使用`serde_json`序列化及反序列化 + +```rust +use serde_json::{json, Value}; +let v:serde_json::Value = json!( + { + "x":20.0, + "y":15.0 + } +); +println!("x:{:#?},y:{:#?}",v["x"],v["y"]); // x:20.0, y:15.0 +``` + +### 3.使用`Serde`宏统一格式化输入、输出字段名称 + +| 方法名 | 方法效果 | +| ------------------------------- | ------------------------------------------------------------ | +| `PascalCase` | 首字母为大写的驼峰式命名,推荐结构体、枚举等名称以及`Yaml`配置文件读取使用。 | +| `camelCase` | 首字母为小写的驼峰式命名,推荐`Yaml`配置文件读取使用。 | +| `snake_case` | 小蛇形命名,用下划线"`_`"连接单词,推荐函数命名以及变量名称使用此种方式。 | +| `SCREAMING_SNAKE_CASE` | 大蛇形命名,单词均为大写形式,用下划线"`_`"连接单词。推荐常数及全局变量使用此种方式。 | +| `kebab-case`(小串烤肉) | 同`snake_case`,使用中横线"`-`"替换了下划线"`_`"。 | +| `SCREAMING-KEBAB-CAS`(大串烤肉) | 同`SCREAMING_SNAKE_CASE`,使用中横线"`-`"替换了下划线"`_`"。 | + +示例: + +```rust +pub struct App { + #[serde(rename_all = "PascalCase")] + /// 统一格式化输入、输出字段名称 + /// #[serde(rename_all = "camelCase")] + /// #[serde(rename_all = "snake_case")] + /// #[serde(rename_all = "SCREAMING_SNAKE_CASE")] + /// 仅设置 + version: String, + app_name: String, + host: String, +} +``` + +[本文参考:yaserde](https://github.com/media-io/yaserde) + +[本文参考:magiclen](https://magiclen.org/rust-serde/) \ No newline at end of file diff --git a/backend/content/posts/rust-sqlx.md b/backend/content/posts/rust-sqlx.md new file mode 100644 index 0000000..40d2539 --- /dev/null +++ b/backend/content/posts/rust-sqlx.md @@ -0,0 +1,37 @@ +--- +title: "Rust Sqlx" +description: +date: 2022-08-29T13:55:08+08:00 +draft: true +slug: rust-sqlx +image: +categories: + - +tags: + - +--- + +# sqlx-cli + +## 创建 migration + +```shell +sqlx migrate add categories +``` + +```sql +-- Add migration script here +CREATE TABLE IF NOT EXISTS categories( + id INT PRIMARY KEY DEFAULT AUTO_INCREMENT, + type_id INT UNIQUE NOT NULL, + parent_id INT NOT NULL, + name TEXT UNIQUE NOT NULL, + ); +``` + +## 运行 migration + +```sh +sqlx migrate run +``` + diff --git a/backend/content/posts/terminal-ui-design.md b/backend/content/posts/terminal-ui-design.md deleted file mode 100644 index 69978ce..0000000 --- a/backend/content/posts/terminal-ui-design.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Terminal UI Design Principles -slug: terminal-ui-design -description: Learn the key principles of designing beautiful terminal-style user interfaces. -category: design -post_type: article -pinned: false -published: true -tags: - - design - - terminal - - ui ---- - -# Terminal UI Design Principles - -Terminal-style interfaces are making a comeback in modern web design. - -## Key Elements - -1. Monospace fonts -2. Dark themes -3. Command prompts -4. ASCII art -5. Blinking cursor - -## Color Palette - -- Background: `#0d1117` -- Text: `#c9d1d9` -- Accent: `#58a6ff` -- Success: `#3fb950` -- Warning: `#d29922` -- Error: `#f85149` - -## Implementation - -Use CSS to create the terminal aesthetic while maintaining accessibility. diff --git a/backend/content/posts/tmux.md b/backend/content/posts/tmux.md new file mode 100644 index 0000000..48c7274 --- /dev/null +++ b/backend/content/posts/tmux.md @@ -0,0 +1,54 @@ +--- +title: "在 Tmux 会话窗格中发送命令的方法" +slug: tmux +description: "介绍如何在 Tmux 中创建分离会话、向指定窗格发送命令并执行回车,同时说明连接会话和发送特殊按键的基本用法。" +category: "Linux" +post_type: "article" +pinned: false +published: true +tags: + - "Tmux" + - "终端复用" + - "send-keys" + - "会话管理" + - "命令行" +--- + +## 在 Tmux 会话窗格中发送命令的方法 + +在 `Tmux` 中,可以使用 `send-keys` 命令将命令发送到会话窗格中。以下是在 `Tmux` 中发送命令的步骤: + +### 1. 新建一个分离(`Detached`)会话 + +使用以下命令新建一个分离会话: + +```bash +tmux new -d -s mySession +``` + +### 2. 发送命令至会话窗格 + +使用以下命令将命令发送到会话窗格: + +```bash +tmux send-keys -t mySession "echo 'Hello World!'" ENTER +``` + +这将发送 `echo 'Hello World!'` 命令,并模拟按下回车键(`ENTER`),以在会话窗格中执行该命令。 + +### 3. 连接(`Attach`)会话窗格 + +使用以下命令连接会话窗格: + +```bash +tmux a -t mySession +``` + +这将连接到名为 `mySession` 的会话窗格。 + +### 4. 发送特殊命令 + +要发送特殊命令,例如清除当前行或使用管理员权限运行命令,请使用以下命令: + +- 清除当前行:`tmux send-keys C-c` +- 以管理员身份运行命令:`sudo tmux send-keys ...` diff --git a/backend/content/posts/welcome-to-termi.md b/backend/content/posts/welcome-to-termi.md deleted file mode 100644 index 0c2dc7c..0000000 --- a/backend/content/posts/welcome-to-termi.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Welcome to Termi Blog -slug: welcome-to-termi -description: Welcome to our new blog built with Astro and Loco.rs backend. -category: general -post_type: article -pinned: true -published: true -tags: - - welcome - - astro - - loco-rs ---- - -# Welcome to Termi Blog - -This is the first post on our new blog built with Astro and Loco.rs backend. - -## Features - -- Fast performance with Astro -- Terminal-style UI design -- Comments system -- Friend links -- Tags and categories - -## Code Example - -```rust -fn main() { - println!("Hello, Termi!"); -} -``` - -Stay tuned for more posts! diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..aab6826 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +if [ "${TERMI_SKIP_MIGRATIONS:-false}" != "true" ]; then + echo "[entrypoint] running database migrations..." + termi_api-cli -e production db migrate +fi + +exec "$@" diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs index 93cf863..5e524d9 100644 --- a/backend/migration/src/lib.rs +++ b/backend/migration/src/lib.rs @@ -13,6 +13,29 @@ mod m20260328_000002_create_site_settings; mod m20260328_000003_add_site_url_to_site_settings; mod m20260328_000004_add_posts_search_index; mod m20260328_000005_categories; +mod m20260328_000006_add_ai_to_site_settings; +mod m20260328_000007_create_ai_chunks; +mod m20260328_000008_enable_pgvector_for_ai_chunks; +mod m20260328_000009_add_paragraph_comments; +mod m20260328_000010_add_paragraph_comments_toggle_to_site_settings; +mod m20260328_000011_add_post_images_and_music_playlist; +mod m20260329_000012_add_link_url_to_reviews; +mod m20260329_000013_add_ai_provider_presets_to_site_settings; +mod m20260329_000014_create_query_events; +mod m20260330_000015_add_image_ai_settings_to_site_settings; +mod m20260330_000016_add_r2_media_settings_to_site_settings; +mod m20260330_000017_add_media_storage_provider_to_site_settings; +mod m20260331_000018_add_comment_request_metadata; +mod m20260331_000019_create_comment_blacklist; +mod m20260331_000020_create_comment_persona_analysis_logs; +mod m20260331_000021_add_post_lifecycle_and_seo; +mod m20260331_000022_add_site_settings_notifications_and_seo; +mod m20260331_000023_create_content_events; +mod m20260331_000024_create_admin_audit_logs; +mod m20260331_000025_create_post_revisions; +mod m20260331_000026_create_subscriptions; +mod m20260331_000027_create_notification_deliveries; +mod m20260331_000028_expand_subscriptions_and_deliveries; pub struct Migrator; #[async_trait::async_trait] @@ -30,6 +53,29 @@ impl MigratorTrait for Migrator { Box::new(m20260328_000003_add_site_url_to_site_settings::Migration), Box::new(m20260328_000004_add_posts_search_index::Migration), Box::new(m20260328_000005_categories::Migration), + Box::new(m20260328_000006_add_ai_to_site_settings::Migration), + Box::new(m20260328_000007_create_ai_chunks::Migration), + Box::new(m20260328_000008_enable_pgvector_for_ai_chunks::Migration), + Box::new(m20260328_000009_add_paragraph_comments::Migration), + Box::new(m20260328_000010_add_paragraph_comments_toggle_to_site_settings::Migration), + Box::new(m20260328_000011_add_post_images_and_music_playlist::Migration), + Box::new(m20260329_000012_add_link_url_to_reviews::Migration), + Box::new(m20260329_000013_add_ai_provider_presets_to_site_settings::Migration), + Box::new(m20260329_000014_create_query_events::Migration), + Box::new(m20260330_000015_add_image_ai_settings_to_site_settings::Migration), + Box::new(m20260330_000016_add_r2_media_settings_to_site_settings::Migration), + Box::new(m20260330_000017_add_media_storage_provider_to_site_settings::Migration), + Box::new(m20260331_000018_add_comment_request_metadata::Migration), + Box::new(m20260331_000019_create_comment_blacklist::Migration), + Box::new(m20260331_000020_create_comment_persona_analysis_logs::Migration), + Box::new(m20260331_000021_add_post_lifecycle_and_seo::Migration), + Box::new(m20260331_000022_add_site_settings_notifications_and_seo::Migration), + Box::new(m20260331_000023_create_content_events::Migration), + Box::new(m20260331_000024_create_admin_audit_logs::Migration), + Box::new(m20260331_000025_create_post_revisions::Migration), + Box::new(m20260331_000026_create_subscriptions::Migration), + Box::new(m20260331_000027_create_notification_deliveries::Migration), + Box::new(m20260331_000028_expand_subscriptions_and_deliveries::Migration), // inject-above (do not remove this comment) ] } diff --git a/backend/migration/src/m20260328_000006_add_ai_to_site_settings.rs b/backend/migration/src/m20260328_000006_add_ai_to_site_settings.rs new file mode 100644 index 0000000..a6349f1 --- /dev/null +++ b/backend/migration/src/m20260328_000006_add_ai_to_site_settings.rs @@ -0,0 +1,175 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager.has_column("site_settings", "ai_enabled").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("ai_enabled")) + .boolean() + .null() + .default(false), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_provider").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_provider")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_api_base").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_api_base")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_api_key").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_api_key")).text().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_chat_model").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_chat_model")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_embedding_model") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("ai_embedding_model")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_system_prompt") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_system_prompt")).text().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_top_k").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_top_k")).integer().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("site_settings", "ai_chunk_size").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_chunk_size")).integer().null()) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_last_indexed_at") + .await? + { + manager + .alter_table( + Table::alter() + .table(table) + .add_column( + ColumnDef::new(Alias::new("ai_last_indexed_at")) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + for column in [ + "ai_last_indexed_at", + "ai_chunk_size", + "ai_top_k", + "ai_system_prompt", + "ai_embedding_model", + "ai_chat_model", + "ai_api_key", + "ai_api_base", + "ai_provider", + "ai_enabled", + ] { + if manager.has_column("site_settings", column).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260328_000007_create_ai_chunks.rs b/backend/migration/src/m20260328_000007_create_ai_chunks.rs new file mode 100644 index 0000000..89f6188 --- /dev/null +++ b/backend/migration/src/m20260328_000007_create_ai_chunks.rs @@ -0,0 +1,33 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_table( + manager, + "ai_chunks", + &[ + ("id", ColType::PkAuto), + ("source_slug", ColType::String), + ("source_title", ColType::StringNull), + ("source_path", ColType::StringNull), + ("source_type", ColType::String), + ("chunk_index", ColType::Integer), + ("content", ColType::Text), + ("content_preview", ColType::StringNull), + ("embedding", ColType::JsonBinaryNull), + ("word_count", ColType::IntegerNull), + ], + &[], + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + drop_table(manager, "ai_chunks").await + } +} diff --git a/backend/migration/src/m20260328_000008_enable_pgvector_for_ai_chunks.rs b/backend/migration/src/m20260328_000008_enable_pgvector_for_ai_chunks.rs new file mode 100644 index 0000000..a1d74b1 --- /dev/null +++ b/backend/migration/src/m20260328_000008_enable_pgvector_for_ai_chunks.rs @@ -0,0 +1,69 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const AI_CHUNKS_TABLE: &str = "ai_chunks"; +const VECTOR_INDEX_NAME: &str = "idx_ai_chunks_embedding_hnsw"; +const EMBEDDING_DIMENSION: i32 = 384; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("CREATE EXTENSION IF NOT EXISTS vector") + .await?; + + if manager.has_column(AI_CHUNKS_TABLE, "embedding").await? { + manager + .get_connection() + .execute_unprepared("ALTER TABLE ai_chunks DROP COLUMN embedding") + .await?; + } + + if !manager.has_column(AI_CHUNKS_TABLE, "embedding").await? { + manager + .get_connection() + .execute_unprepared(&format!( + "ALTER TABLE ai_chunks ADD COLUMN embedding vector({EMBEDDING_DIMENSION})" + )) + .await?; + } + + manager + .get_connection() + .execute_unprepared("TRUNCATE TABLE ai_chunks RESTART IDENTITY") + .await?; + + manager + .get_connection() + .execute_unprepared(&format!( + "CREATE INDEX IF NOT EXISTS {VECTOR_INDEX_NAME} ON ai_chunks USING hnsw (embedding vector_cosine_ops)" + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared(&format!("DROP INDEX IF EXISTS {VECTOR_INDEX_NAME}")) + .await?; + + if manager.has_column(AI_CHUNKS_TABLE, "embedding").await? { + manager + .get_connection() + .execute_unprepared("ALTER TABLE ai_chunks DROP COLUMN embedding") + .await?; + } + + manager + .get_connection() + .execute_unprepared("ALTER TABLE ai_chunks ADD COLUMN embedding jsonb") + .await?; + + Ok(()) + } +} diff --git a/backend/migration/src/m20260328_000009_add_paragraph_comments.rs b/backend/migration/src/m20260328_000009_add_paragraph_comments.rs new file mode 100644 index 0000000..627de97 --- /dev/null +++ b/backend/migration/src/m20260328_000009_add_paragraph_comments.rs @@ -0,0 +1,113 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const TABLE: &str = "comments"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new(TABLE); + + if !manager.has_column(TABLE, "scope").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("scope")) + .string() + .not_null() + .default("article"), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column(TABLE, "paragraph_key").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("paragraph_key")).string().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column(TABLE, "paragraph_excerpt").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("paragraph_excerpt")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column(TABLE, "reply_to_comment_id").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("reply_to_comment_id")) + .integer() + .null(), + ) + .to_owned(), + ) + .await?; + } + + manager + .get_connection() + .execute_unprepared( + "UPDATE comments SET scope = 'article' WHERE scope IS NULL OR trim(scope) = ''", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_comments_post_scope_paragraph ON comments (post_slug, scope, paragraph_key)", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP INDEX IF EXISTS idx_comments_post_scope_paragraph") + .await?; + + for column in [ + "reply_to_comment_id", + "paragraph_excerpt", + "paragraph_key", + "scope", + ] { + if manager.has_column(TABLE, column).await? { + manager + .alter_table( + Table::alter() + .table(Alias::new(TABLE)) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260328_000010_add_paragraph_comments_toggle_to_site_settings.rs b/backend/migration/src/m20260328_000010_add_paragraph_comments_toggle_to_site_settings.rs new file mode 100644 index 0000000..2f44832 --- /dev/null +++ b/backend/migration/src/m20260328_000010_add_paragraph_comments_toggle_to_site_settings.rs @@ -0,0 +1,48 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if !manager + .has_column("site_settings", "paragraph_comments_enabled") + .await? + { + manager + .alter_table( + Table::alter() + .table(Alias::new("site_settings")) + .add_column( + ColumnDef::new(Alias::new("paragraph_comments_enabled")) + .boolean() + .null() + .default(true), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if manager + .has_column("site_settings", "paragraph_comments_enabled") + .await? + { + manager + .alter_table( + Table::alter() + .table(Alias::new("site_settings")) + .drop_column(Alias::new("paragraph_comments_enabled")) + .to_owned(), + ) + .await?; + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260328_000011_add_post_images_and_music_playlist.rs b/backend/migration/src/m20260328_000011_add_post_images_and_music_playlist.rs new file mode 100644 index 0000000..f99facb --- /dev/null +++ b/backend/migration/src/m20260328_000011_add_post_images_and_music_playlist.rs @@ -0,0 +1,75 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let posts_table = Alias::new("posts"); + let site_settings_table = Alias::new("site_settings"); + + if !manager.has_column("posts", "images").await? { + manager + .alter_table( + Table::alter() + .table(posts_table.clone()) + .add_column(ColumnDef::new(Alias::new("images")).json_binary().null()) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "music_playlist") + .await? + { + manager + .alter_table( + Table::alter() + .table(site_settings_table.clone()) + .add_column( + ColumnDef::new(Alias::new("music_playlist")) + .json_binary() + .null(), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let posts_table = Alias::new("posts"); + let site_settings_table = Alias::new("site_settings"); + + if manager.has_column("posts", "images").await? { + manager + .alter_table( + Table::alter() + .table(posts_table) + .drop_column(Alias::new("images")) + .to_owned(), + ) + .await?; + } + + if manager + .has_column("site_settings", "music_playlist") + .await? + { + manager + .alter_table( + Table::alter() + .table(site_settings_table) + .drop_column(Alias::new("music_playlist")) + .to_owned(), + ) + .await?; + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260329_000012_add_link_url_to_reviews.rs b/backend/migration/src/m20260329_000012_add_link_url_to_reviews.rs new file mode 100644 index 0000000..d152ba4 --- /dev/null +++ b/backend/migration/src/m20260329_000012_add_link_url_to_reviews.rs @@ -0,0 +1,35 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Reviews::Table) + .add_column(ColumnDef::new(Reviews::LinkUrl).string().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Reviews::Table) + .drop_column(Reviews::LinkUrl) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Reviews { + Table, + LinkUrl, +} diff --git a/backend/migration/src/m20260329_000013_add_ai_provider_presets_to_site_settings.rs b/backend/migration/src/m20260329_000013_add_ai_provider_presets_to_site_settings.rs new file mode 100644 index 0000000..64a12fd --- /dev/null +++ b/backend/migration/src/m20260329_000013_add_ai_provider_presets_to_site_settings.rs @@ -0,0 +1,98 @@ +use sea_orm::{DbBackend, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager.has_column("site_settings", "ai_providers").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("ai_providers")) + .json_binary() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_active_provider_id") + .await? + { + manager + .alter_table( + Table::alter() + .table(table) + .add_column( + ColumnDef::new(Alias::new("ai_active_provider_id")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + manager + .get_connection() + .execute(Statement::from_string( + DbBackend::Postgres, + r#" + UPDATE site_settings + SET + ai_providers = jsonb_build_array( + jsonb_strip_nulls( + jsonb_build_object( + 'id', 'default', + 'name', COALESCE(NULLIF(trim(ai_provider), ''), '默认提供商'), + 'provider', COALESCE(NULLIF(trim(ai_provider), ''), 'newapi'), + 'api_base', NULLIF(trim(ai_api_base), ''), + 'api_key', NULLIF(trim(ai_api_key), ''), + 'chat_model', NULLIF(trim(ai_chat_model), '') + ) + ) + ), + ai_active_provider_id = COALESCE(NULLIF(trim(ai_active_provider_id), ''), 'default') + WHERE ai_providers IS NULL + AND ( + COALESCE(trim(ai_provider), '') <> '' + OR COALESCE(trim(ai_api_base), '') <> '' + OR COALESCE(trim(ai_api_key), '') <> '' + OR COALESCE(trim(ai_chat_model), '') <> '' + ) + "# + .to_string(), + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + for column in ["ai_active_provider_id", "ai_providers"] { + if manager.has_column("site_settings", column).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260329_000014_create_query_events.rs b/backend/migration/src/m20260329_000014_create_query_events.rs new file mode 100644 index 0000000..ac17085 --- /dev/null +++ b/backend/migration/src/m20260329_000014_create_query_events.rs @@ -0,0 +1,73 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_table( + manager, + "query_events", + &[ + ("id", ColType::PkAuto), + ("event_type", ColType::String), + ("query_text", ColType::Text), + ("normalized_query", ColType::Text), + ("request_path", ColType::StringNull), + ("referrer", ColType::StringNull), + ("user_agent", ColType::TextNull), + ("result_count", ColType::IntegerNull), + ("success", ColType::BooleanNull), + ("response_mode", ColType::StringNull), + ("provider", ColType::StringNull), + ("chat_model", ColType::StringNull), + ("latency_ms", ColType::IntegerNull), + ], + &[], + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_query_events_event_type_created_at") + .table(Alias::new("query_events")) + .col(Alias::new("event_type")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_query_events_normalized_query") + .table(Alias::new("query_events")) + .col(Alias::new("normalized_query")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for index_name in [ + "idx_query_events_normalized_query", + "idx_query_events_event_type_created_at", + ] { + manager + .drop_index( + Index::drop() + .name(index_name) + .table(Alias::new("query_events")) + .to_owned(), + ) + .await?; + } + + drop_table(manager, "query_events").await + } +} diff --git a/backend/migration/src/m20260330_000015_add_image_ai_settings_to_site_settings.rs b/backend/migration/src/m20260330_000015_add_image_ai_settings_to_site_settings.rs new file mode 100644 index 0000000..8b00ee1 --- /dev/null +++ b/backend/migration/src/m20260330_000015_add_image_ai_settings_to_site_settings.rs @@ -0,0 +1,101 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager + .has_column("site_settings", "ai_image_provider") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("ai_image_provider")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_image_api_base") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("ai_image_api_base")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_image_api_key") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("ai_image_api_key")).text().null()) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "ai_image_model") + .await? + { + manager + .alter_table( + Table::alter() + .table(table) + .add_column(ColumnDef::new(Alias::new("ai_image_model")).string().null()) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + for column in [ + "ai_image_model", + "ai_image_api_key", + "ai_image_api_base", + "ai_image_provider", + ] { + if manager.has_column("site_settings", column).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260330_000016_add_r2_media_settings_to_site_settings.rs b/backend/migration/src/m20260330_000016_add_r2_media_settings_to_site_settings.rs new file mode 100644 index 0000000..a0f1a95 --- /dev/null +++ b/backend/migration/src/m20260330_000016_add_r2_media_settings_to_site_settings.rs @@ -0,0 +1,128 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager + .has_column("site_settings", "media_r2_account_id") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("media_r2_account_id")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "media_r2_bucket") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("media_r2_bucket")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "media_r2_public_base_url") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("media_r2_public_base_url")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "media_r2_access_key_id") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("media_r2_access_key_id")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "media_r2_secret_access_key") + .await? + { + manager + .alter_table( + Table::alter() + .table(table) + .add_column( + ColumnDef::new(Alias::new("media_r2_secret_access_key")) + .text() + .null(), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + for column in [ + "media_r2_secret_access_key", + "media_r2_access_key_id", + "media_r2_public_base_url", + "media_r2_bucket", + "media_r2_account_id", + ] { + if manager.has_column("site_settings", column).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260330_000017_add_media_storage_provider_to_site_settings.rs b/backend/migration/src/m20260330_000017_add_media_storage_provider_to_site_settings.rs new file mode 100644 index 0000000..d30d598 --- /dev/null +++ b/backend/migration/src/m20260330_000017_add_media_storage_provider_to_site_settings.rs @@ -0,0 +1,53 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if manager + .has_column("site_settings", "media_storage_provider") + .await? + { + return Ok(()); + } + + manager + .alter_table( + Table::alter() + .table(SiteSettings::Table) + .add_column( + ColumnDef::new(SiteSettings::MediaStorageProvider) + .string() + .null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + if !manager + .has_column("site_settings", "media_storage_provider") + .await? + { + return Ok(()); + } + + manager + .alter_table( + Table::alter() + .table(SiteSettings::Table) + .drop_column(SiteSettings::MediaStorageProvider) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum SiteSettings { + Table, + MediaStorageProvider, +} diff --git a/backend/migration/src/m20260331_000018_add_comment_request_metadata.rs b/backend/migration/src/m20260331_000018_add_comment_request_metadata.rs new file mode 100644 index 0000000..5b1e766 --- /dev/null +++ b/backend/migration/src/m20260331_000018_add_comment_request_metadata.rs @@ -0,0 +1,85 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const TABLE: &str = "comments"; +const IP_ADDRESS_COLUMN: &str = "ip_address"; +const USER_AGENT_COLUMN: &str = "user_agent"; +const REFERER_COLUMN: &str = "referer"; +const COMMENT_IP_INDEX: &str = "idx_comments_ip_address"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new(TABLE); + + if !manager.has_column(TABLE, IP_ADDRESS_COLUMN).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new(IP_ADDRESS_COLUMN)) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column(TABLE, USER_AGENT_COLUMN).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new(USER_AGENT_COLUMN)).text().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column(TABLE, REFERER_COLUMN).await? { + manager + .alter_table( + Table::alter() + .table(table) + .add_column(ColumnDef::new(Alias::new(REFERER_COLUMN)).text().null()) + .to_owned(), + ) + .await?; + } + + manager + .get_connection() + .execute_unprepared(&format!( + "CREATE INDEX IF NOT EXISTS {COMMENT_IP_INDEX} ON {TABLE} ({IP_ADDRESS_COLUMN})" + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared(&format!("DROP INDEX IF EXISTS {COMMENT_IP_INDEX}")) + .await?; + + for column in [REFERER_COLUMN, USER_AGENT_COLUMN, IP_ADDRESS_COLUMN] { + if manager.has_column(TABLE, column).await? { + manager + .alter_table( + Table::alter() + .table(Alias::new(TABLE)) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260331_000019_create_comment_blacklist.rs b/backend/migration/src/m20260331_000019_create_comment_blacklist.rs new file mode 100644 index 0000000..a787f44 --- /dev/null +++ b/backend/migration/src/m20260331_000019_create_comment_blacklist.rs @@ -0,0 +1,103 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("comment_blacklist")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(Alias::new("matcher_type")) + .string() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("matcher_value")) + .string() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("reason")).text().null()) + .col(ColumnDef::new(Alias::new("active")).boolean().null()) + .col( + ColumnDef::new(Alias::new("expires_at")) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_comment_blacklist_matcher") + .table(Alias::new("comment_blacklist")) + .col(Alias::new("matcher_type")) + .col(Alias::new("matcher_value")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_comment_blacklist_active_expires") + .table(Alias::new("comment_blacklist")) + .col(Alias::new("active")) + .col(Alias::new("expires_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for index_name in [ + "idx_comment_blacklist_active_expires", + "idx_comment_blacklist_matcher", + ] { + manager + .drop_index( + Index::drop() + .name(index_name) + .table(Alias::new("comment_blacklist")) + .to_owned(), + ) + .await?; + } + + manager + .drop_table( + Table::drop() + .table(Alias::new("comment_blacklist")) + .if_exists() + .to_owned(), + ) + .await + } +} diff --git a/backend/migration/src/m20260331_000020_create_comment_persona_analysis_logs.rs b/backend/migration/src/m20260331_000020_create_comment_persona_analysis_logs.rs new file mode 100644 index 0000000..f20b4eb --- /dev/null +++ b/backend/migration/src/m20260331_000020_create_comment_persona_analysis_logs.rs @@ -0,0 +1,131 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("comment_persona_analysis_logs")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(Alias::new("matcher_type")) + .string() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("matcher_value")) + .string() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("from_at")) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Alias::new("to_at")) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Alias::new("total_comments")) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("pending_comments")) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("distinct_posts")) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("analysis_text")) + .text() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("sample_json")) + .json_binary() + .null(), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_comment_persona_analysis_logs_matcher_created_at") + .table(Alias::new("comment_persona_analysis_logs")) + .col(Alias::new("matcher_type")) + .col(Alias::new("matcher_value")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_comment_persona_analysis_logs_created_at") + .table(Alias::new("comment_persona_analysis_logs")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for index_name in [ + "idx_comment_persona_analysis_logs_created_at", + "idx_comment_persona_analysis_logs_matcher_created_at", + ] { + manager + .drop_index( + Index::drop() + .name(index_name) + .table(Alias::new("comment_persona_analysis_logs")) + .to_owned(), + ) + .await?; + } + + manager + .drop_table( + Table::drop() + .table(Alias::new("comment_persona_analysis_logs")) + .if_exists() + .to_owned(), + ) + .await + } +} diff --git a/backend/migration/src/m20260331_000021_add_post_lifecycle_and_seo.rs b/backend/migration/src/m20260331_000021_add_post_lifecycle_and_seo.rs new file mode 100644 index 0000000..d99ca87 --- /dev/null +++ b/backend/migration/src/m20260331_000021_add_post_lifecycle_and_seo.rs @@ -0,0 +1,168 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("posts"); + + if !manager.has_column("posts", "status").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("status")) + .string() + .null() + .default("published"), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("posts", "visibility").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("visibility")) + .string() + .null() + .default("public"), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("posts", "publish_at").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("publish_at")) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("posts", "unpublish_at").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("unpublish_at")) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("posts", "canonical_url").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("canonical_url")).text().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("posts", "noindex").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("noindex")) + .boolean() + .null() + .default(false), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("posts", "og_image").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("og_image")).text().null()) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("posts", "redirect_from").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("redirect_from")) + .json_binary() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager.has_column("posts", "redirect_to").await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column(ColumnDef::new(Alias::new("redirect_to")).text().null()) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("posts"); + + for column in [ + "redirect_to", + "redirect_from", + "og_image", + "noindex", + "canonical_url", + "unpublish_at", + "publish_at", + "visibility", + "status", + ] { + if manager.has_column("posts", column).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260331_000022_add_site_settings_notifications_and_seo.rs b/backend/migration/src/m20260331_000022_add_site_settings_notifications_and_seo.rs new file mode 100644 index 0000000..abc869b --- /dev/null +++ b/backend/migration/src/m20260331_000022_add_site_settings_notifications_and_seo.rs @@ -0,0 +1,149 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + if !manager + .has_column("site_settings", "seo_default_og_image") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("seo_default_og_image")) + .text() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "seo_default_twitter_handle") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("seo_default_twitter_handle")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "notification_webhook_url") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("notification_webhook_url")) + .text() + .null(), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "notification_comment_enabled") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("notification_comment_enabled")) + .boolean() + .null() + .default(false), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "notification_friend_link_enabled") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("notification_friend_link_enabled")) + .boolean() + .null() + .default(false), + ) + .to_owned(), + ) + .await?; + } + + if !manager + .has_column("site_settings", "search_synonyms") + .await? + { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .add_column( + ColumnDef::new(Alias::new("search_synonyms")) + .json_binary() + .null(), + ) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = Alias::new("site_settings"); + + for column in [ + "search_synonyms", + "notification_friend_link_enabled", + "notification_comment_enabled", + "notification_webhook_url", + "seo_default_twitter_handle", + "seo_default_og_image", + ] { + if manager.has_column("site_settings", column).await? { + manager + .alter_table( + Table::alter() + .table(table.clone()) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } +} diff --git a/backend/migration/src/m20260331_000023_create_content_events.rs b/backend/migration/src/m20260331_000023_create_content_events.rs new file mode 100644 index 0000000..0e6e49d --- /dev/null +++ b/backend/migration/src/m20260331_000023_create_content_events.rs @@ -0,0 +1,82 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_table( + manager, + "content_events", + &[ + ("id", ColType::PkAuto), + ("event_type", ColType::String), + ("path", ColType::String), + ("post_slug", ColType::StringNull), + ("session_id", ColType::StringNull), + ("referrer", ColType::StringNull), + ("user_agent", ColType::TextNull), + ("duration_ms", ColType::IntegerNull), + ("progress_percent", ColType::IntegerNull), + ("metadata", ColType::JsonBinaryNull), + ], + &[], + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_content_events_event_type_created_at") + .table(Alias::new("content_events")) + .col(Alias::new("event_type")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_content_events_post_slug_created_at") + .table(Alias::new("content_events")) + .col(Alias::new("post_slug")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_content_events_referrer") + .table(Alias::new("content_events")) + .col(Alias::new("referrer")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for index_name in [ + "idx_content_events_referrer", + "idx_content_events_post_slug_created_at", + "idx_content_events_event_type_created_at", + ] { + manager + .drop_index( + Index::drop() + .name(index_name) + .table(Alias::new("content_events")) + .to_owned(), + ) + .await?; + } + + drop_table(manager, "content_events").await + } +} diff --git a/backend/migration/src/m20260331_000024_create_admin_audit_logs.rs b/backend/migration/src/m20260331_000024_create_admin_audit_logs.rs new file mode 100644 index 0000000..1f487f7 --- /dev/null +++ b/backend/migration/src/m20260331_000024_create_admin_audit_logs.rs @@ -0,0 +1,70 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_table( + manager, + "admin_audit_logs", + &[ + ("id", ColType::PkAuto), + ("actor_username", ColType::StringNull), + ("actor_email", ColType::StringNull), + ("actor_source", ColType::StringNull), + ("action", ColType::String), + ("target_type", ColType::String), + ("target_id", ColType::StringNull), + ("target_label", ColType::StringNull), + ("metadata", ColType::JsonBinaryNull), + ], + &[], + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_admin_audit_logs_action_created_at") + .table(Alias::new("admin_audit_logs")) + .col(Alias::new("action")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_admin_audit_logs_target_type_created_at") + .table(Alias::new("admin_audit_logs")) + .col(Alias::new("target_type")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for index_name in [ + "idx_admin_audit_logs_target_type_created_at", + "idx_admin_audit_logs_action_created_at", + ] { + manager + .drop_index( + Index::drop() + .name(index_name) + .table(Alias::new("admin_audit_logs")) + .to_owned(), + ) + .await?; + } + + drop_table(manager, "admin_audit_logs").await + } +} diff --git a/backend/migration/src/m20260331_000025_create_post_revisions.rs b/backend/migration/src/m20260331_000025_create_post_revisions.rs new file mode 100644 index 0000000..ef441db --- /dev/null +++ b/backend/migration/src/m20260331_000025_create_post_revisions.rs @@ -0,0 +1,71 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_table( + manager, + "post_revisions", + &[ + ("id", ColType::PkAuto), + ("post_slug", ColType::String), + ("post_title", ColType::StringNull), + ("operation", ColType::String), + ("revision_reason", ColType::TextNull), + ("actor_username", ColType::StringNull), + ("actor_email", ColType::StringNull), + ("actor_source", ColType::StringNull), + ("markdown", ColType::TextNull), + ("metadata", ColType::JsonBinaryNull), + ], + &[], + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_post_revisions_post_slug_created_at") + .table(Alias::new("post_revisions")) + .col(Alias::new("post_slug")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_post_revisions_operation_created_at") + .table(Alias::new("post_revisions")) + .col(Alias::new("operation")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for index_name in [ + "idx_post_revisions_operation_created_at", + "idx_post_revisions_post_slug_created_at", + ] { + manager + .drop_index( + Index::drop() + .name(index_name) + .table(Alias::new("post_revisions")) + .to_owned(), + ) + .await?; + } + + drop_table(manager, "post_revisions").await + } +} diff --git a/backend/migration/src/m20260331_000026_create_subscriptions.rs b/backend/migration/src/m20260331_000026_create_subscriptions.rs new file mode 100644 index 0000000..4e4da81 --- /dev/null +++ b/backend/migration/src/m20260331_000026_create_subscriptions.rs @@ -0,0 +1,74 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_table( + manager, + "subscriptions", + &[ + ("id", ColType::PkAuto), + ("channel_type", ColType::String), + ("target", ColType::String), + ("display_name", ColType::StringNull), + ("status", ColType::String), + ("filters", ColType::JsonBinaryNull), + ("secret", ColType::TextNull), + ("notes", ColType::TextNull), + ("verified_at", ColType::StringNull), + ("last_notified_at", ColType::StringNull), + ("failure_count", ColType::IntegerNull), + ("last_delivery_status", ColType::StringNull), + ], + &[], + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_subscriptions_channel_status") + .table(Alias::new("subscriptions")) + .col(Alias::new("channel_type")) + .col(Alias::new("status")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_subscriptions_channel_target_unique") + .table(Alias::new("subscriptions")) + .col(Alias::new("channel_type")) + .col(Alias::new("target")) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for index_name in [ + "idx_subscriptions_channel_target_unique", + "idx_subscriptions_channel_status", + ] { + manager + .drop_index( + Index::drop() + .name(index_name) + .table(Alias::new("subscriptions")) + .to_owned(), + ) + .await?; + } + + drop_table(manager, "subscriptions").await + } +} diff --git a/backend/migration/src/m20260331_000027_create_notification_deliveries.rs b/backend/migration/src/m20260331_000027_create_notification_deliveries.rs new file mode 100644 index 0000000..01d4c63 --- /dev/null +++ b/backend/migration/src/m20260331_000027_create_notification_deliveries.rs @@ -0,0 +1,71 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_table( + manager, + "notification_deliveries", + &[ + ("id", ColType::PkAuto), + ("subscription_id", ColType::IntegerNull), + ("channel_type", ColType::String), + ("target", ColType::String), + ("event_type", ColType::String), + ("status", ColType::String), + ("provider", ColType::StringNull), + ("response_text", ColType::TextNull), + ("payload", ColType::JsonBinaryNull), + ("delivered_at", ColType::StringNull), + ], + &[], + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_notification_deliveries_event_created_at") + .table(Alias::new("notification_deliveries")) + .col(Alias::new("event_type")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_notification_deliveries_subscription_created_at") + .table(Alias::new("notification_deliveries")) + .col(Alias::new("subscription_id")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for index_name in [ + "idx_notification_deliveries_subscription_created_at", + "idx_notification_deliveries_event_created_at", + ] { + manager + .drop_index( + Index::drop() + .name(index_name) + .table(Alias::new("notification_deliveries")) + .to_owned(), + ) + .await?; + } + + drop_table(manager, "notification_deliveries").await + } +} diff --git a/backend/migration/src/m20260331_000028_expand_subscriptions_and_deliveries.rs b/backend/migration/src/m20260331_000028_expand_subscriptions_and_deliveries.rs new file mode 100644 index 0000000..486852c --- /dev/null +++ b/backend/migration/src/m20260331_000028_expand_subscriptions_and_deliveries.rs @@ -0,0 +1,144 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("subscriptions")) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("confirm_token")) + .string() + .null(), + ) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("manage_token")) + .string() + .null(), + ) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("metadata")) + .json_binary() + .null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_subscriptions_confirm_token_unique") + .table(Alias::new("subscriptions")) + .col(Alias::new("confirm_token")) + .unique() + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_subscriptions_manage_token_unique") + .table(Alias::new("subscriptions")) + .col(Alias::new("manage_token")) + .unique() + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("notification_deliveries")) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("attempts_count")) + .integer() + .not_null() + .default(0), + ) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("next_retry_at")) + .string() + .null(), + ) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("last_attempt_at")) + .string() + .null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_notification_deliveries_status_next_retry") + .table(Alias::new("notification_deliveries")) + .col(Alias::new("status")) + .col(Alias::new("next_retry_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_notification_deliveries_status_next_retry") + .table(Alias::new("notification_deliveries")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("notification_deliveries")) + .drop_column(Alias::new("last_attempt_at")) + .drop_column(Alias::new("next_retry_at")) + .drop_column(Alias::new("attempts_count")) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_subscriptions_manage_token_unique") + .table(Alias::new("subscriptions")) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_subscriptions_confirm_token_unique") + .table(Alias::new("subscriptions")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("subscriptions")) + .drop_column(Alias::new("metadata")) + .drop_column(Alias::new("manage_token")) + .drop_column(Alias::new("confirm_token")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/backend/src/app.rs b/backend/src/app.rs index 9d7d7b7..77cd0ae 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -21,9 +21,11 @@ use tower_http::cors::{Any, CorsLayer}; #[allow(unused_imports)] use crate::{ controllers, initializers, - models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags, users}, + models::_entities::{ + ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users, + }, tasks, - workers::downloader::DownloadWorker, + workers::{downloader::DownloadWorker, notification_delivery::NotificationDeliveryWorker}, }; pub struct App; @@ -52,15 +54,14 @@ impl Hooks for App { } async fn initializers(_ctx: &AppContext) -> Result>> { - Ok(vec![ - Box::new(initializers::content_sync::ContentSyncInitializer), - Box::new(initializers::view_engine::ViewEngineInitializer), - ]) + Ok(vec![Box::new(initializers::content_sync::ContentSyncInitializer)]) } fn routes(_ctx: &AppContext) -> AppRoutes { AppRoutes::with_default_routes() // controller routes below - .add_route(controllers::admin::routes()) + .add_route(controllers::health::routes()) + .add_route(controllers::admin_api::routes()) + .add_route(controllers::admin_ops::routes()) .add_route(controllers::review::routes()) .add_route(controllers::category::routes()) .add_route(controllers::friend_link::routes()) @@ -68,30 +69,39 @@ impl Hooks for App { .add_route(controllers::comment::routes()) .add_route(controllers::post::routes()) .add_route(controllers::search::routes()) + .add_route(controllers::content_analytics::routes()) .add_route(controllers::site_settings::routes()) + .add_route(controllers::ai::routes()) .add_route(controllers::auth::routes()) + .add_route(controllers::subscription::routes()) } async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result { let cors = CorsLayer::new() .allow_origin(Any) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE]) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + ]) .allow_headers(Any); Ok(router.layer(cors)) } async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { queue.register(DownloadWorker::build(ctx)).await?; + queue.register(NotificationDeliveryWorker::build(ctx)).await?; Ok(()) } #[allow(unused_variables)] fn register_tasks(tasks: &mut Tasks) { + tasks.register(tasks::retry_deliveries::RetryDeliveries); + tasks.register(tasks::send_weekly_digest::SendWeeklyDigest); + tasks.register(tasks::send_monthly_digest::SendMonthlyDigest); // tasks-inject (do not remove) } - async fn truncate(ctx: &AppContext) -> Result<()> { - truncate_table(&ctx.db, users::Entity).await?; - Ok(()) - } async fn seed(ctx: &AppContext, base: &Path) -> Result<()> { // Seed users - use loco's default seed which handles duplicates let users_file = base.join("users.yaml"); @@ -271,48 +281,88 @@ impl Hooks for App { }) .filter(|items| !items.is_empty()) .map(|items| serde_json::json!(items)); + let music_playlist = settings["music_playlist"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| { + let title = item["title"].as_str()?.trim(); + let url = item["url"].as_str()?.trim(); + if title.is_empty() || url.is_empty() { + None + } else { + Some(serde_json::json!({ + "title": title, + "url": url, + })) + } + }) + .collect::>() + }) + .filter(|items| !items.is_empty()) + .map(serde_json::Value::Array); let item = site_settings::ActiveModel { id: Set(settings["id"].as_i64().unwrap_or(1) as i32), site_name: Set(settings["site_name"].as_str().map(ToString::to_string)), - site_short_name: Set( - settings["site_short_name"].as_str().map(ToString::to_string), - ), + site_short_name: Set(settings["site_short_name"] + .as_str() + .map(ToString::to_string)), site_url: Set(settings["site_url"].as_str().map(ToString::to_string)), site_title: Set(settings["site_title"].as_str().map(ToString::to_string)), - site_description: Set( - settings["site_description"].as_str().map(ToString::to_string), - ), + site_description: Set(settings["site_description"] + .as_str() + .map(ToString::to_string)), hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)), - hero_subtitle: Set( - settings["hero_subtitle"].as_str().map(ToString::to_string), - ), + hero_subtitle: Set(settings["hero_subtitle"] + .as_str() + .map(ToString::to_string)), owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)), - owner_title: Set( - settings["owner_title"].as_str().map(ToString::to_string), - ), + owner_title: Set(settings["owner_title"].as_str().map(ToString::to_string)), owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)), - owner_avatar_url: Set( - settings["owner_avatar_url"].as_str().and_then(|value| { + owner_avatar_url: Set(settings["owner_avatar_url"].as_str().and_then( + |value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } - }), - ), - social_github: Set( - settings["social_github"].as_str().map(ToString::to_string), - ), - social_twitter: Set( - settings["social_twitter"].as_str().map(ToString::to_string), - ), - social_email: Set( - settings["social_email"].as_str().map(ToString::to_string), - ), + }, + )), + social_github: Set(settings["social_github"] + .as_str() + .map(ToString::to_string)), + social_twitter: Set(settings["social_twitter"] + .as_str() + .map(ToString::to_string)), + social_email: Set(settings["social_email"] + .as_str() + .map(ToString::to_string)), location: Set(settings["location"].as_str().map(ToString::to_string)), tech_stack: Set(tech_stack), + music_playlist: Set(music_playlist), + ai_enabled: Set(settings["ai_enabled"].as_bool()), + paragraph_comments_enabled: Set(settings["paragraph_comments_enabled"] + .as_bool() + .or(Some(true))), + ai_provider: Set(settings["ai_provider"].as_str().map(ToString::to_string)), + ai_api_base: Set(settings["ai_api_base"].as_str().map(ToString::to_string)), + ai_api_key: Set(settings["ai_api_key"].as_str().map(ToString::to_string)), + ai_chat_model: Set(settings["ai_chat_model"] + .as_str() + .map(ToString::to_string)), + ai_embedding_model: Set(settings["ai_embedding_model"] + .as_str() + .map(ToString::to_string)), + ai_system_prompt: Set(settings["ai_system_prompt"] + .as_str() + .map(ToString::to_string)), + ai_top_k: Set(settings["ai_top_k"].as_i64().map(|value| value as i32)), + ai_chunk_size: Set(settings["ai_chunk_size"] + .as_i64() + .map(|value| value as i32)), ..Default::default() }; let _ = item.insert(&ctx.db).await; @@ -332,6 +382,11 @@ impl Hooks for App { let status = review["status"].as_str().unwrap_or("completed").to_string(); let description = review["description"].as_str().unwrap_or("").to_string(); let cover = review["cover"].as_str().unwrap_or("📝").to_string(); + let link_url = review["link_url"] + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); let tags_vec = review["tags"] .as_array() .map(|arr| { @@ -355,6 +410,7 @@ impl Hooks for App { status: Set(Some(status)), description: Set(Some(description)), cover: Set(Some(cover)), + link_url: Set(link_url), tags: Set(Some(serde_json::to_string(&tags_vec).unwrap_or_default())), ..Default::default() }; @@ -365,4 +421,10 @@ impl Hooks for App { Ok(()) } + + async fn truncate(ctx: &AppContext) -> Result<()> { + truncate_table(&ctx.db, ai_chunks::Entity).await?; + truncate_table(&ctx.db, users::Entity).await?; + Ok(()) + } } diff --git a/backend/src/controllers/admin.rs b/backend/src/controllers/admin.rs index 44a27ce..64fcff5 100644 --- a/backend/src/controllers/admin.rs +++ b/backend/src/controllers/admin.rs @@ -1,1231 +1,248 @@ -use axum::{extract::{Multipart, Query, State}, Form}; +use axum::http::{header, HeaderMap}; use loco_rs::prelude::*; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, Set}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use std::collections::BTreeMap; -use std::sync::atomic::{AtomicBool, Ordering}; +use serde::Serialize; +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; +use uuid::Uuid; -use crate::models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags}; -use crate::services::content; +const DEFAULT_ADMIN_USERNAME: &str = "admin"; +const DEFAULT_ADMIN_PASSWORD: &str = "admin123"; +const DEFAULT_SESSION_COOKIE_NAME: &str = "termi_admin_session"; +const DEFAULT_SESSION_TTL_SECONDS: i64 = 43_200; -static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false); -const FRONTEND_BASE_URL: &str = "http://localhost:4321"; +#[derive(Clone, Debug, Serialize)] +pub struct AdminIdentity { + pub username: String, + pub email: Option, + pub source: String, + pub provider: Option, + pub groups: Vec, +} -#[derive(Deserialize)] -pub struct LoginForm { +#[derive(Clone, Debug)] +struct LocalSessionRecord { username: String, - password: String, + email: Option, + expires_at: chrono::DateTime, } -#[derive(Default, Deserialize)] -pub struct LoginQuery { - error: Option, +static ADMIN_SESSIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn env_flag(name: &str, default: bool) -> bool { + std::env::var(name) + .ok() + .map(|value| match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => default, + }) + .unwrap_or(default) } -#[derive(Serialize)] -struct HeaderAction { - label: String, - href: String, - variant: String, - external: bool, +fn session_cookie_name() -> String { + std::env::var("TERMI_ADMIN_SESSION_COOKIE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_SESSION_COOKIE_NAME.to_string()) } -#[derive(Serialize)] -struct StatCard { - label: String, - value: String, - note: String, - tone: String, +fn proxy_shared_secret() -> Option { + std::env::var("TERMI_ADMIN_PROXY_SHARED_SECRET") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } -#[derive(Serialize)] -struct NavCard { - title: String, - description: String, - href: String, - meta: String, +fn session_ttl_seconds() -> i64 { + std::env::var("TERMI_ADMIN_SESSION_TTL_SECONDS") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_SESSION_TTL_SECONDS) } -#[derive(Serialize)] -struct SiteProfile { - site_name: String, - site_description: String, - site_url: String, -} - -#[derive(Serialize)] -struct PostRow { - id: i32, - title: String, - slug: String, - file_path: String, - created_at: String, - category_name: String, - category_frontend_url: String, - frontend_url: String, - api_url: String, - edit_url: String, - tags: Vec, -} - -#[derive(Deserialize)] -pub struct PostCreateForm { - title: String, - slug: String, - description: String, - category: String, - tags: String, - post_type: String, - image: String, - pinned: Option, - published: Option, - content: String, -} - -#[derive(Serialize)] -struct CommentRow { - id: i32, - author: String, - post_slug: String, - content: String, - approved: bool, - created_at: String, - frontend_url: Option, - api_url: String, -} - -#[derive(Serialize)] -struct TagRow { - id: i32, - name: String, - slug: String, - usage_count: usize, - frontend_url: String, - articles_url: String, - api_url: String, -} - -#[derive(Serialize)] -struct CategoryRow { - id: i32, - name: String, - slug: String, - count: usize, - latest_title: String, - latest_frontend_url: Option, - frontend_url: String, - articles_url: String, - api_url: String, -} - -#[derive(Serialize)] -struct ReviewRow { - id: i32, - title: String, - review_type: String, - rating: i32, - review_date: String, - status: String, - description: String, - tags_input: String, - cover: String, - api_url: String, -} - -#[derive(Serialize)] -struct FriendLinkRow { - id: i32, - site_name: String, - site_url: String, - description: String, - category_name: String, - status: String, - created_at: String, - frontend_page_url: String, - api_url: String, -} - -#[derive(Deserialize)] -pub struct CategoryForm { - name: String, - slug: String, -} - -#[derive(Deserialize)] -pub struct TagForm { - name: String, - slug: String, -} - -#[derive(Deserialize)] -pub struct ReviewForm { - title: String, - review_type: String, - rating: i32, - review_date: String, - status: String, - description: String, - tags: String, - cover: String, -} - -fn url_encode(value: &str) -> String { - let mut encoded = String::new(); - for byte in value.as_bytes() { - match byte { - b'A'..=b'Z' - | b'a'..=b'z' - | b'0'..=b'9' - | b'-' - | b'_' - | b'.' - | b'~' => encoded.push(*byte as char), - b' ' => encoded.push_str("%20"), - _ => encoded.push_str(&format!("%{byte:02X}")), - } - } - encoded -} - -fn frontend_path(path: &str) -> String { - format!("{FRONTEND_BASE_URL}{path}") -} - -fn frontend_query_url(path: &str, key: &str, value: &str) -> String { - format!("{}{path}?{key}={}", FRONTEND_BASE_URL, url_encode(value)) -} - -fn slugify(value: &str) -> String { - let mut slug = String::new(); - let mut last_was_dash = false; - - for ch in value.trim().chars() { - if ch.is_ascii_alphanumeric() { - slug.push(ch.to_ascii_lowercase()); - last_was_dash = false; - } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash { - slug.push('-'); - last_was_dash = true; - } - } - - slug.trim_matches('-').to_string() -} - -fn non_empty(value: Option<&str>, fallback: &str) -> String { - value - .map(str::trim) - .filter(|text| !text.is_empty()) - .unwrap_or(fallback) - .to_string() -} - -fn normalize_admin_text(value: &str) -> String { - value.trim().to_string() -} - -fn normalized_slug_or_name(slug: &str, name: &str) -> String { - let normalized_slug = slug.trim(); - if normalized_slug.is_empty() { - slugify(name) - } else { - slugify(normalized_slug) - } -} - -fn parse_review_tags(input: &str) -> Vec { - input - .split(',') +fn header_value(headers: &HeaderMap, key: &'static str) -> Option { + headers + .get(key) + .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) +} + +fn split_groups(value: Option) -> Vec { + value.unwrap_or_default() + .split([',', ';', ' ']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) .collect() } -fn parse_tag_input(input: &str) -> Vec { - input - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .collect() +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| { + raw.split(';').find_map(|part| { + let (key, value) = part.trim().split_once('=')?; + (key == name).then(|| value.trim().to_string()) + }) + }) } -fn review_tags_input(value: Option<&str>) -> String { - value - .and_then(|item| serde_json::from_str::>(item).ok()) - .unwrap_or_default() - .join(", ") -} - -fn json_string_array(value: &Option) -> Vec { - value - .as_ref() - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .into_iter() - .filter_map(|item| item.as_str().map(ToString::to_string)) - .collect() -} - -fn matches_tag(post_tag: &str, tag_name: &str, tag_slug: &str) -> bool { - let left = post_tag.trim().to_lowercase(); - let name = tag_name.trim().to_lowercase(); - let slug = tag_slug.trim().to_lowercase(); - - left == name || left == slug -} - -fn link_status_text(status: &str) -> &'static str { - match status { - "approved" => "已通过", - "rejected" => "已拒绝", - _ => "待审核", +fn resolve_proxy_identity(headers: &HeaderMap) -> Option { + if !proxy_auth_enabled() { + return None; } -} -fn page_context(title: &str, description: &str, active_nav: &str) -> Map { - let mut context = Map::new(); - context.insert("page_title".into(), json!(title)); - context.insert("page_description".into(), json!(description)); - context.insert("active_nav".into(), json!(active_nav)); - context.insert("frontend_base_url".into(), json!(FRONTEND_BASE_URL)); - context -} - -fn action(label: &str, href: String, variant: &str, external: bool) -> HeaderAction { - HeaderAction { - label: label.to_string(), - href, - variant: variant.to_string(), - external, + if let Some(expected_secret) = proxy_shared_secret() { + let provided_secret = header_value(headers, "X-Termi-Proxy-Secret")?; + if provided_secret != expected_secret { + tracing::warn!("proxy auth secret mismatch, ignoring forwarded admin identity headers"); + return None; + } } -} -fn render_admin( - view_engine: &ViewEngine, - template: &str, - context: Map, -) -> Result { - format::view(&view_engine.0, template, Value::Object(context)) -} + let username = [ + header_value(headers, "Remote-User"), + header_value(headers, "X-Forwarded-User"), + header_value(headers, "X-Auth-Request-User"), + ] + .into_iter() + .flatten() + .find(|value| !value.is_empty())?; -fn check_auth() -> Result<()> { - if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) { - return Err(Error::Unauthorized("Not logged in".to_string())); - } - Ok(()) -} + let email = [ + header_value(headers, "Remote-Email"), + header_value(headers, "X-Forwarded-Email"), + header_value(headers, "X-Auth-Request-Email"), + ] + .into_iter() + .flatten() + .find(|value| !value.is_empty()); -pub async fn root() -> Result { - if ADMIN_LOGGED_IN.load(Ordering::SeqCst) { - Ok(format::redirect("/admin")) + let provider = if header_value(headers, "Remote-User").is_some() { + Some("TinyAuth".to_string()) } else { - Ok(format::redirect("/admin/login")) - } -} - -pub async fn login_page( - view_engine: ViewEngine, - Query(query): Query, -) -> Result { - format::view( - &view_engine.0, - "admin/login.html", - json!({ - "page_title": "后台登录", - "page_description": "登录后可进入统一后台,审核评论和友链、检查分类标签,并管理站点信息。", - "show_error": query.error.is_some(), - }), - ) -} - -pub async fn login_submit(Form(form): Form) -> Result { - if form.username == "admin" && form.password == "admin123" { - ADMIN_LOGGED_IN.store(true, Ordering::SeqCst); - return Ok(format::redirect("/admin")); - } - Ok(format::redirect("/admin/login?error=1")) -} - -pub async fn logout() -> Result { - ADMIN_LOGGED_IN.store(false, Ordering::SeqCst); - Ok(format::redirect("/admin/login")) -} - -pub async fn index( - view_engine: ViewEngine, - State(ctx): State, -) -> Result { - check_auth()?; - content::sync_markdown_posts(&ctx).await?; - - let posts = posts::Entity::find() - .order_by_desc(posts::Column::CreatedAt) - .all(&ctx.db) - .await?; - let comments_count = comments::Entity::find().count(&ctx.db).await?; - let reviews_count = reviews::Entity::find().count(&ctx.db).await?; - let categories_count = categories::Entity::find().count(&ctx.db).await?; - let tags_count = tags::Entity::find().count(&ctx.db).await?; - let friend_links_count = friend_links::Entity::find().count(&ctx.db).await?; - let pending_comments = comments::Entity::find() - .filter(comments::Column::Approved.eq(false)) - .count(&ctx.db) - .await?; - let pending_links = friend_links::Entity::find() - .filter(friend_links::Column::Status.eq("pending")) - .count(&ctx.db) - .await?; - - let site = site_settings::Entity::find() - .order_by_asc(site_settings::Column::Id) - .one(&ctx.db) - .await?; - - let stats = vec![ - StatCard { - label: "文章".to_string(), - value: posts.len().to_string(), - note: "当前前台文章与推文总数".to_string(), - tone: "blue".to_string(), - }, - StatCard { - label: "评论".to_string(), - value: comments_count.to_string(), - note: format!("待审核 {pending_comments} 条"), - tone: "gold".to_string(), - }, - StatCard { - label: "分类".to_string(), - value: categories_count.to_string(), - note: "导入文章会自动同步分类".to_string(), - tone: "green".to_string(), - }, - StatCard { - label: "标签".to_string(), - value: tags_count.to_string(), - note: "支持前台标签页与筛选".to_string(), - tone: "pink".to_string(), - }, - StatCard { - label: "评价".to_string(), - value: reviews_count.to_string(), - note: "后台可创建并同步前台".to_string(), - tone: "blue".to_string(), - }, - StatCard { - label: "友链".to_string(), - value: friend_links_count.to_string(), - note: format!("待审核 {pending_links} 条"), - tone: "violet".to_string(), - }, - ]; - - let nav_cards = vec![ - NavCard { - title: "文章管理".to_string(), - description: "查看文章、跳转前台详情,并联动分类和标签。".to_string(), - href: "/admin/posts".to_string(), - meta: format!("{} 篇内容", posts.len()), - }, - NavCard { - title: "评论审核".to_string(), - description: "审核前台真实评论,并直接跳到对应文章。".to_string(), - href: "/admin/comments".to_string(), - meta: format!("{pending_comments} 条待审核"), - }, - NavCard { - title: "分类管理".to_string(), - description: "维护分类字典,文章导入时会自动匹配或创建。".to_string(), - href: "/admin/categories".to_string(), - meta: format!("{categories_count} 个分类"), - }, - NavCard { - title: "标签管理".to_string(), - description: "维护标签字典,文章导入时会自动匹配或创建。".to_string(), - href: "/admin/tags".to_string(), - meta: format!("{tags_count} 个标签"), - }, - NavCard { - title: "评价管理".to_string(), - description: "创建和编辑评价内容,前台评价页直接读取真实数据。".to_string(), - href: "/admin/reviews".to_string(), - meta: format!("{reviews_count} 条评价"), - }, - NavCard { - title: "友链申请".to_string(), - description: "审核友链状态,查看前台列表与外部站点。".to_string(), - href: "/admin/friend_links".to_string(), - meta: format!("{pending_links} 条待处理"), - }, - NavCard { - title: "站点设置".to_string(), - description: "修改首页、关于页、页脚与友链页读取的站点信息。".to_string(), - href: "/admin/site-settings".to_string(), - meta: "支持实时预览入口".to_string(), - }, - ]; - - let profile = SiteProfile { - site_name: non_empty(site.as_ref().and_then(|item| item.site_name.as_deref()), "未配置站点"), - site_description: non_empty( - site.as_ref() - .and_then(|item| item.site_description.as_deref()), - "站点简介尚未设置", - ), - site_url: non_empty(site.as_ref().and_then(|item| item.site_url.as_deref()), "未配置站点链接"), + Some("Proxy SSO".to_string()) }; - let mut context = page_context("后台总览", "前后台共用同一份数据,这里可以快速处理内容和跳转前台。", "dashboard"); - context.insert( - "header_actions".into(), - json!([ - action("打开前台首页", frontend_path("/"), "ghost", true), - action("查看关于页", frontend_path("/about"), "ghost", true), - action("查看友链页", frontend_path("/friends"), "primary", true), - ]), - ); - context.insert("stats".into(), json!(stats)); - context.insert("nav_cards".into(), json!(nav_cards)); - context.insert("site_profile".into(), json!(profile)); - - render_admin(&view_engine, "admin/index.html", context) + Some(AdminIdentity { + username, + email, + source: "proxy".to_string(), + provider, + groups: split_groups( + header_value(headers, "Remote-Groups") + .or_else(|| header_value(headers, "X-Forwarded-Groups")), + ), + }) } -pub async fn posts_admin( - view_engine: ViewEngine, - State(ctx): State, -) -> Result { - check_auth()?; - let markdown_posts = content::sync_markdown_posts(&ctx).await?; +fn resolve_local_identity(headers: &HeaderMap) -> Option { + let token = cookie_value(headers, &session_cookie_name())?; + let now = chrono::Utc::now(); + let mut guard = ADMIN_SESSIONS.lock().ok()?; - let items = posts::Entity::find() - .order_by_desc(posts::Column::CreatedAt) - .all(&ctx.db) - .await?; + let session = match guard.get(&token) { + Some(session) if session.expires_at > now => session.clone(), + Some(_) => { + guard.remove(&token); + return None; + } + None => return None, + }; - let file_path_by_slug = markdown_posts - .into_iter() - .map(|post| (post.slug, post.file_path)) - .collect::>(); - - let rows = items - .iter() - .map(|post| { - let category_name = non_empty(post.category.as_deref(), "未分类"); - PostRow { - id: post.id, - title: non_empty(post.title.as_deref(), "未命名文章"), - slug: post.slug.clone(), - file_path: file_path_by_slug - .get(&post.slug) - .cloned() - .unwrap_or_else(|| content::markdown_post_path(&post.slug).to_string_lossy().to_string()), - created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(), - category_name: category_name.clone(), - category_frontend_url: frontend_query_url("/articles", "category", &category_name), - frontend_url: frontend_path(&format!("/articles/{}", post.slug)), - api_url: format!("/api/posts/{}", post.id), - edit_url: format!("/admin/posts/{}/edit", post.slug), - tags: json_string_array(&post.tags), - } - }) - .collect::>(); - - let mut context = page_context("文章管理", "核对文章、分类和标签,并可直接跳到前台详情页。", "posts"); - context.insert( - "header_actions".into(), - json!([ - action("前台文章列表", frontend_path("/articles"), "primary", true), - action("前台分类页", frontend_path("/categories"), "ghost", true), - action("文章 API", "/api/posts".to_string(), "ghost", true), - ]), - ); - context.insert( - "create_form".into(), - json!({ - "title": "", - "slug": "", - "description": "", - "category": "", - "tags": "", - "post_type": "article", - "image": "", - "content": "# 新文章\n\n在这里开始写作。\n", - }), - ); - context.insert("rows".into(), json!(rows)); - - render_admin(&view_engine, "admin/posts.html", context) + Some(AdminIdentity { + username: session.username, + email: session.email, + source: "local".to_string(), + provider: Some("Built-in admin session".to_string()), + groups: Vec::new(), + }) } -pub async fn posts_create( - State(ctx): State, - Form(form): Form, -) -> Result { - check_auth()?; +pub(crate) fn admin_username() -> String { + std::env::var("TERMI_ADMIN_USERNAME").unwrap_or_else(|_| DEFAULT_ADMIN_USERNAME.to_string()) +} - let _ = content::create_markdown_post( - &ctx, - content::MarkdownPostDraft { - title: normalize_admin_text(&form.title), - slug: Some(normalized_slug_or_name(&form.slug, &form.title)), - description: Some(normalize_admin_text(&form.description)), - content: normalize_admin_text(&form.content), - category: Some(normalize_admin_text(&form.category)), - tags: parse_tag_input(&form.tags), - post_type: normalize_admin_text(&form.post_type), - image: Some(normalize_admin_text(&form.image)), - pinned: form.pinned.is_some(), - published: form.published.is_some(), +pub(crate) fn admin_password() -> String { + std::env::var("TERMI_ADMIN_PASSWORD").unwrap_or_else(|_| DEFAULT_ADMIN_PASSWORD.to_string()) +} + +pub(crate) fn proxy_auth_enabled() -> bool { + env_flag("TERMI_ADMIN_TRUST_PROXY_AUTH", false) +} + +pub(crate) fn local_login_enabled() -> bool { + env_flag("TERMI_ADMIN_LOCAL_LOGIN_ENABLED", true) +} + +pub(crate) fn validate_admin_credentials(username: &str, password: &str) -> bool { + username == admin_username() && password == admin_password() +} + +pub(crate) fn resolve_admin_identity(headers: &HeaderMap) -> Option { + resolve_proxy_identity(headers).or_else(|| resolve_local_identity(headers)) +} + +pub(crate) fn check_auth(headers: &HeaderMap) -> Result { + resolve_admin_identity(headers) + .ok_or_else(|| Error::Unauthorized("Not logged in".to_string())) +} + +pub(crate) fn start_local_session(username: &str) -> (AdminIdentity, String, String) { + let token = Uuid::new_v4().to_string(); + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(session_ttl_seconds()); + + let record = LocalSessionRecord { + username: username.to_string(), + email: None, + expires_at, + }; + + if let Ok(mut sessions) = ADMIN_SESSIONS.lock() { + sessions.insert(token.clone(), record); + } + + let cookie = format!( + "{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}", + session_cookie_name(), + token, + session_ttl_seconds() + ); + + ( + AdminIdentity { + username: username.to_string(), + email: None, + source: "local".to_string(), + provider: Some("Built-in admin session".to_string()), + groups: Vec::new(), }, + token, + cookie, ) - .await?; - - Ok(format::redirect("/admin/posts")) } -pub async fn posts_import( - State(ctx): State, - mut multipart: Multipart, -) -> Result { - check_auth()?; +pub(crate) fn clear_local_session(headers: &HeaderMap) { + let Some(token) = cookie_value(headers, &session_cookie_name()) else { + return; + }; - let mut files = Vec::new(); - - while let Some(field) = multipart.next_field().await.map_err(|error| Error::BadRequest(error.to_string()))? { - let file_name = field - .file_name() - .map(ToString::to_string) - .unwrap_or_else(|| "imported.md".to_string()); - let bytes = field - .bytes() - .await - .map_err(|error| Error::BadRequest(error.to_string()))?; - let content = String::from_utf8(bytes.to_vec()) - .map_err(|_| Error::BadRequest("markdown file must be utf-8".to_string()))?; - - files.push(content::MarkdownImportFile { file_name, content }); + if let Ok(mut sessions) = ADMIN_SESSIONS.lock() { + sessions.remove(&token); } - - let imported = content::import_markdown_documents(&ctx, files).await?; - format::json(json!({ - "count": imported.len(), - "slugs": imported.iter().map(|item| item.slug.clone()).collect::>(), - })) } -pub async fn post_editor( - view_engine: ViewEngine, - Path(slug): Path, - State(ctx): State, -) -> Result { - check_auth()?; - content::sync_markdown_posts(&ctx).await?; - - let (file_path, markdown) = content::read_markdown_document(&slug)?; - let post = posts::Entity::find() - .filter(posts::Column::Slug.eq(&slug)) - .one(&ctx.db) - .await? - .ok_or(Error::NotFound)?; - - let mut context = page_context( - "Markdown 编辑器", - "这里保存的就是服务器上的 Markdown 文件。你也可以直接用本地编辑器改这个文件,刷新后后台和前台都会同步。", - "posts", - ); - context.insert( - "header_actions".into(), - json!([ - action("返回文章管理", "/admin/posts".to_string(), "ghost", false), - action("前台预览", frontend_path(&format!("/articles/{}", slug)), "primary", true), - action("文章 API", format!("/api/posts/slug/{slug}"), "ghost", true), - action("Markdown API", format!("/api/posts/slug/{slug}/markdown"), "ghost", true), - ]), - ); - context.insert( - "editor".into(), - json!({ - "title": non_empty(post.title.as_deref(), "未命名文章"), - "slug": slug, - "file_path": file_path, - "markdown": markdown, - }), - ); - - render_admin(&view_engine, "admin/post_editor.html", context) -} - -pub async fn comments_admin( - view_engine: ViewEngine, - State(ctx): State, -) -> Result { - check_auth()?; - content::sync_markdown_posts(&ctx).await?; - - let items = comments::Entity::find() - .order_by_desc(comments::Column::CreatedAt) - .all(&ctx.db) - .await?; - - let rows = items - .iter() - .map(|comment| { - let post_slug = non_empty(comment.post_slug.as_deref(), "未关联文章"); - CommentRow { - id: comment.id, - author: non_empty(comment.author.as_deref(), "匿名"), - post_slug: post_slug.clone(), - content: non_empty(comment.content.as_deref(), "-"), - approved: comment.approved.unwrap_or(false), - created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(), - frontend_url: comment - .post_slug - .as_deref() - .filter(|slug| !slug.trim().is_empty()) - .map(|slug| frontend_path(&format!("/articles/{slug}"))), - api_url: format!("/api/comments/{}", comment.id), - } - }) - .collect::>(); - - let mut context = page_context("评论审核", "前台真实评论会先进入这里,审核通过后再展示到文章页。", "comments"); - context.insert( - "header_actions".into(), - json!([ - action("前台文章列表", frontend_path("/articles"), "primary", true), - action("评论 API", "/api/comments".to_string(), "ghost", true), - ]), - ); - context.insert("rows".into(), json!(rows)); - - render_admin(&view_engine, "admin/comments.html", context) -} - -pub async fn categories_admin( - view_engine: ViewEngine, - State(ctx): State, -) -> Result { - check_auth()?; - content::sync_markdown_posts(&ctx).await?; - - let category_items = categories::Entity::find() - .order_by_asc(categories::Column::Slug) - .all(&ctx.db) - .await?; - let post_items = posts::Entity::find() - .order_by_desc(posts::Column::CreatedAt) - .all(&ctx.db) - .await?; - - let mut rows = category_items - .into_iter() - .map(|category| { - let name = non_empty(category.name.as_deref(), "未命名分类"); - let latest = post_items - .iter() - .find(|post| post.category.as_deref().map(str::trim) == Some(name.as_str())); - let count = post_items - .iter() - .filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str())) - .count(); - - CategoryRow { - id: category.id, - slug: category.slug, - count, - latest_title: latest - .and_then(|post| post.title.as_deref()) - .unwrap_or("最近文章") - .to_string(), - latest_frontend_url: latest.map(|post| frontend_path(&format!("/articles/{}", post.slug))), - frontend_url: frontend_path("/categories"), - articles_url: frontend_query_url("/articles", "category", &name), - api_url: format!("/api/categories/{}", category.id), - name, - } - }) - .collect::>(); - - rows.sort_by(|left, right| { - right - .count - .cmp(&left.count) - .then_with(|| left.name.cmp(&right.name)) - }); - - let mut context = page_context("分类管理", "维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。", "categories"); - context.insert( - "header_actions".into(), - json!([ - action("前台分类页", frontend_path("/categories"), "primary", true), - action("分类 API", "/api/categories".to_string(), "ghost", true), - action("前台文章列表", frontend_path("/articles"), "ghost", true), - ]), - ); - context.insert( - "create_form".into(), - json!({ - "name": "", - "slug": "", - }), - ); - context.insert("rows".into(), json!(rows)); - - render_admin(&view_engine, "admin/categories.html", context) -} - -pub async fn categories_create( - State(ctx): State, - Form(form): Form, -) -> Result { - check_auth()?; - - let name = normalize_admin_text(&form.name); - if name.is_empty() { - return Ok(format::redirect("/admin/categories")); - } - - let slug = normalized_slug_or_name(&form.slug, &name); - let existing = categories::Entity::find() - .filter(categories::Column::Slug.eq(&slug)) - .one(&ctx.db) - .await?; - - if let Some(item) = existing { - let mut model = item.into_active_model(); - model.name = Set(Some(name)); - model.slug = Set(slug); - let _ = model.update(&ctx.db).await?; - } else { - let _ = categories::ActiveModel { - name: Set(Some(name)), - slug: Set(slug), - ..Default::default() - } - .insert(&ctx.db) - .await?; - } - - Ok(format::redirect("/admin/categories")) -} - -pub async fn categories_update( - Path(id): Path, - State(ctx): State, - Form(form): Form, -) -> Result { - check_auth()?; - - let name = normalize_admin_text(&form.name); - if name.is_empty() { - return Ok(format::redirect("/admin/categories")); - } - - let slug = normalized_slug_or_name(&form.slug, &name); - let item = categories::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or(Error::NotFound)?; - - let mut model = item.into_active_model(); - model.name = Set(Some(name)); - model.slug = Set(slug); - let _ = model.update(&ctx.db).await?; - - Ok(format::redirect("/admin/categories")) -} - -pub async fn categories_delete( - Path(id): Path, - State(ctx): State, -) -> Result { - check_auth()?; - - if let Some(item) = categories::Entity::find_by_id(id).one(&ctx.db).await? { - let _ = item.delete(&ctx.db).await?; - } - - Ok(format::redirect("/admin/categories")) -} - -pub async fn tags_admin( - view_engine: ViewEngine, - State(ctx): State, -) -> Result { - check_auth()?; - content::sync_markdown_posts(&ctx).await?; - - let tag_items = tags::Entity::find() - .order_by_asc(tags::Column::Slug) - .all(&ctx.db) - .await?; - let post_items = posts::Entity::find().all(&ctx.db).await?; - - let rows = tag_items - .iter() - .map(|tag| { - let tag_name = non_empty(tag.name.as_deref(), "未命名标签"); - let tag_key = if tag.slug.trim().is_empty() { - tag_name.clone() - } else { - tag.slug.clone() - }; - let usage_count = post_items - .iter() - .filter(|post| { - json_string_array(&post.tags) - .iter() - .any(|item| matches_tag(item, &tag_name, &tag.slug)) - }) - .count(); - - TagRow { - id: tag.id, - name: tag_name.clone(), - slug: tag.slug.clone(), - usage_count, - frontend_url: frontend_query_url("/tags", "tag", &tag_key), - articles_url: frontend_query_url("/articles", "tag", &tag_key), - api_url: format!("/api/tags/{}", tag.id), - } - }) - .collect::>(); - - let mut context = page_context("标签管理", "维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。", "tags"); - context.insert( - "header_actions".into(), - json!([ - action("前台标签页", frontend_path("/tags"), "primary", true), - action("标签 API", "/api/tags".to_string(), "ghost", true), - action("前台文章列表", frontend_path("/articles"), "ghost", true), - ]), - ); - context.insert( - "create_form".into(), - json!({ - "name": "", - "slug": "", - }), - ); - context.insert("rows".into(), json!(rows)); - - render_admin(&view_engine, "admin/tags.html", context) -} - -pub async fn tags_create( - State(ctx): State, - Form(form): Form, -) -> Result { - check_auth()?; - - let name = normalize_admin_text(&form.name); - if name.is_empty() { - return Ok(format::redirect("/admin/tags")); - } - - let slug = normalized_slug_or_name(&form.slug, &name); - let existing = tags::Entity::find() - .filter(tags::Column::Slug.eq(&slug)) - .one(&ctx.db) - .await?; - - if let Some(item) = existing { - let mut model = item.into_active_model(); - model.name = Set(Some(name)); - model.slug = Set(slug); - let _ = model.update(&ctx.db).await?; - } else { - let _ = tags::ActiveModel { - name: Set(Some(name)), - slug: Set(slug), - ..Default::default() - } - .insert(&ctx.db) - .await?; - } - - Ok(format::redirect("/admin/tags")) -} - -pub async fn tags_update( - Path(id): Path, - State(ctx): State, - Form(form): Form, -) -> Result { - check_auth()?; - - let name = normalize_admin_text(&form.name); - if name.is_empty() { - return Ok(format::redirect("/admin/tags")); - } - - let slug = normalized_slug_or_name(&form.slug, &name); - let item = tags::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or(Error::NotFound)?; - - let mut model = item.into_active_model(); - model.name = Set(Some(name)); - model.slug = Set(slug); - let _ = model.update(&ctx.db).await?; - - Ok(format::redirect("/admin/tags")) -} - -pub async fn tags_delete( - Path(id): Path, - State(ctx): State, -) -> Result { - check_auth()?; - - if let Some(item) = tags::Entity::find_by_id(id).one(&ctx.db).await? { - let _ = item.delete(&ctx.db).await?; - } - - Ok(format::redirect("/admin/tags")) -} - -pub async fn reviews_admin( - view_engine: ViewEngine, - State(ctx): State, -) -> Result { - check_auth()?; - content::sync_markdown_posts(&ctx).await?; - - let items = reviews::Entity::find() - .order_by_desc(reviews::Column::CreatedAt) - .all(&ctx.db) - .await?; - - let rows = items - .iter() - .map(|review| ReviewRow { - id: review.id, - title: non_empty(review.title.as_deref(), "未命名评价"), - review_type: non_empty(review.review_type.as_deref(), "game"), - rating: review.rating.unwrap_or(0), - review_date: non_empty(review.review_date.as_deref(), ""), - status: non_empty(review.status.as_deref(), "completed"), - description: non_empty(review.description.as_deref(), ""), - tags_input: review_tags_input(review.tags.as_deref()), - cover: non_empty(review.cover.as_deref(), "🎮"), - api_url: format!("/api/reviews/{}", review.id), - }) - .collect::>(); - - let mut context = page_context("评价管理", "创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。", "reviews"); - context.insert( - "header_actions".into(), - json!([ - action("前台评价页", frontend_path("/reviews"), "primary", true), - action("评价 API", "/api/reviews".to_string(), "ghost", true), - ]), - ); - context.insert( - "create_form".into(), - json!({ - "title": "", - "review_type": "game", - "rating": 5, - "review_date": "", - "status": "completed", - "description": "", - "tags": "", - "cover": "🎮", - }), - ); - context.insert("rows".into(), json!(rows)); - - render_admin(&view_engine, "admin/reviews.html", context) -} - -pub async fn reviews_create( - State(ctx): State, - Form(form): Form, -) -> Result { - check_auth()?; - - let _ = reviews::ActiveModel { - title: Set(Some(normalize_admin_text(&form.title))), - review_type: Set(Some(normalize_admin_text(&form.review_type))), - rating: Set(Some(form.rating)), - review_date: Set(Some(normalize_admin_text(&form.review_date))), - status: Set(Some(normalize_admin_text(&form.status))), - description: Set(Some(normalize_admin_text(&form.description))), - tags: Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())), - cover: Set(Some(normalize_admin_text(&form.cover))), - ..Default::default() - } - .insert(&ctx.db) - .await?; - - Ok(format::redirect("/admin/reviews")) -} - -pub async fn reviews_update( - Path(id): Path, - State(ctx): State, - Form(form): Form, -) -> Result { - check_auth()?; - - let item = reviews::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or(Error::NotFound)?; - - let mut model = item.into_active_model(); - model.title = Set(Some(normalize_admin_text(&form.title))); - model.review_type = Set(Some(normalize_admin_text(&form.review_type))); - model.rating = Set(Some(form.rating)); - model.review_date = Set(Some(normalize_admin_text(&form.review_date))); - model.status = Set(Some(normalize_admin_text(&form.status))); - model.description = Set(Some(normalize_admin_text(&form.description))); - model.tags = Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())); - model.cover = Set(Some(normalize_admin_text(&form.cover))); - let _ = model.update(&ctx.db).await?; - - Ok(format::redirect("/admin/reviews")) -} - -pub async fn reviews_delete( - Path(id): Path, - State(ctx): State, -) -> Result { - check_auth()?; - - if let Some(item) = reviews::Entity::find_by_id(id).one(&ctx.db).await? { - let _ = item.delete(&ctx.db).await?; - } - - Ok(format::redirect("/admin/reviews")) -} - -pub async fn friend_links_admin( - view_engine: ViewEngine, - State(ctx): State, -) -> Result { - check_auth()?; - content::sync_markdown_posts(&ctx).await?; - - let items = friend_links::Entity::find() - .order_by_desc(friend_links::Column::CreatedAt) - .all(&ctx.db) - .await?; - - let rows = items - .iter() - .map(|link| FriendLinkRow { - id: link.id, - site_name: non_empty(link.site_name.as_deref(), "未命名站点"), - site_url: link.site_url.clone(), - description: non_empty(link.description.as_deref(), "暂无描述"), - category_name: non_empty(link.category.as_deref(), "未分类"), - status: link_status_text(link.status.as_deref().unwrap_or("pending")).to_string(), - created_at: link.created_at.format("%Y-%m-%d %H:%M").to_string(), - frontend_page_url: frontend_path("/friends"), - api_url: format!("/api/friend_links/{}", link.id), - }) - .collect::>(); - - let mut context = page_context("友链申请", "处理前台友链申请状态,并跳转到前台友链页或目标站点。", "friend_links"); - context.insert( - "header_actions".into(), - json!([ - action("前台友链页", frontend_path("/friends"), "primary", true), - action("友链 API", "/api/friend_links".to_string(), "ghost", true), - ]), - ); - context.insert("rows".into(), json!(rows)); - - render_admin(&view_engine, "admin/friend_links.html", context) -} - -fn tech_stack_text(item: &site_settings::Model) -> String { - item.tech_stack - .as_ref() - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .iter() - .filter_map(Value::as_str) - .collect::>() - .join("\n") -} - -pub async fn site_settings_admin( - view_engine: ViewEngine, - State(ctx): State, -) -> Result { - check_auth()?; - content::sync_markdown_posts(&ctx).await?; - - let item = site_settings::Entity::find() - .order_by_asc(site_settings::Column::Id) - .one(&ctx.db) - .await? - .ok_or(Error::NotFound)?; - - let mut context = page_context("站点设置", "修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。", "site_settings"); - context.insert( - "header_actions".into(), - json!([ - action("预览首页", frontend_path("/"), "primary", true), - action("预览关于页", frontend_path("/about"), "ghost", true), - action("预览友链页", frontend_path("/friends"), "ghost", true), - action("设置 API", "/api/site_settings".to_string(), "ghost", true), - ]), - ); - context.insert( - "form".into(), - json!({ - "site_name": non_empty(item.site_name.as_deref(), ""), - "site_short_name": non_empty(item.site_short_name.as_deref(), ""), - "site_url": non_empty(item.site_url.as_deref(), ""), - "site_title": non_empty(item.site_title.as_deref(), ""), - "site_description": non_empty(item.site_description.as_deref(), ""), - "hero_title": non_empty(item.hero_title.as_deref(), ""), - "hero_subtitle": non_empty(item.hero_subtitle.as_deref(), ""), - "owner_name": non_empty(item.owner_name.as_deref(), ""), - "owner_title": non_empty(item.owner_title.as_deref(), ""), - "owner_avatar_url": non_empty(item.owner_avatar_url.as_deref(), ""), - "location": non_empty(item.location.as_deref(), ""), - "social_github": non_empty(item.social_github.as_deref(), ""), - "social_twitter": non_empty(item.social_twitter.as_deref(), ""), - "social_email": non_empty(item.social_email.as_deref(), ""), - "owner_bio": non_empty(item.owner_bio.as_deref(), ""), - "tech_stack": tech_stack_text(&item), - }), - ); - - render_admin(&view_engine, "admin/site_settings.html", context) -} - -pub fn routes() -> Routes { - Routes::new() - .add("/", get(root)) - .add("/admin/login", get(login_page).post(login_submit)) - .add("/admin/logout", get(logout)) - .add("/admin", get(index)) - .add("/admin/posts", get(posts_admin).post(posts_create)) - .add("/admin/posts/import", post(posts_import)) - .add("/admin/posts/{slug}/edit", get(post_editor)) - .add("/admin/comments", get(comments_admin)) - .add("/admin/categories", get(categories_admin).post(categories_create)) - .add("/admin/categories/{id}/update", post(categories_update)) - .add("/admin/categories/{id}/delete", post(categories_delete)) - .add("/admin/tags", get(tags_admin).post(tags_create)) - .add("/admin/tags/{id}/update", post(tags_update)) - .add("/admin/tags/{id}/delete", post(tags_delete)) - .add("/admin/reviews", get(reviews_admin).post(reviews_create)) - .add("/admin/reviews/{id}/update", post(reviews_update)) - .add("/admin/reviews/{id}/delete", post(reviews_delete)) - .add("/admin/friend_links", get(friend_links_admin)) - .add("/admin/site-settings", get(site_settings_admin)) +pub(crate) fn clear_local_session_cookie() -> String { + format!( + "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", + session_cookie_name() + ) } diff --git a/backend/src/controllers/admin_api.rs b/backend/src/controllers/admin_api.rs new file mode 100644 index 0000000..55ea247 --- /dev/null +++ b/backend/src/controllers/admin_api.rs @@ -0,0 +1,1817 @@ +use axum::{ + extract::{Multipart, Query}, + http::{header, HeaderMap}, +}; +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, + QueryOrder, QuerySelect, Set, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + controllers::{ + admin::{ + admin_username, check_auth, clear_local_session, clear_local_session_cookie, + local_login_enabled, proxy_auth_enabled, resolve_admin_identity, start_local_session, + validate_admin_credentials, + }, + site_settings::{self, SiteSettingsPayload}, + }, + models::_entities::{ + ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts, + reviews, + }, + services::{admin_audit, ai, analytics, comment_guard, content, storage}, +}; + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminLoginPayload { + pub username: String, + pub password: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminSessionResponse { + pub authenticated: bool, + pub username: Option, + pub email: Option, + pub auth_source: Option, + pub auth_provider: Option, + pub groups: Vec, + pub proxy_auth_enabled: bool, + pub local_login_enabled: bool, + pub can_logout: bool, +} + +fn build_session_response(identity: Option) -> AdminSessionResponse { + let can_logout = matches!(identity.as_ref().map(|item| item.source.as_str()), Some("local")); + + AdminSessionResponse { + authenticated: identity.is_some(), + username: identity.as_ref().map(|item| item.username.clone()), + email: identity.as_ref().and_then(|item| item.email.clone()), + auth_source: identity.as_ref().map(|item| item.source.clone()), + auth_provider: identity.as_ref().and_then(|item| item.provider.clone()), + groups: identity.map(|item| item.groups).unwrap_or_default(), + proxy_auth_enabled: proxy_auth_enabled(), + local_login_enabled: local_login_enabled(), + can_logout, + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct DashboardStats { + pub total_posts: u64, + pub total_comments: u64, + pub pending_comments: u64, + pub draft_posts: u64, + pub scheduled_posts: u64, + pub offline_posts: u64, + pub expired_posts: u64, + pub private_posts: u64, + pub unlisted_posts: u64, + pub total_categories: u64, + pub total_tags: u64, + pub total_reviews: u64, + pub total_links: u64, + pub pending_links: u64, + pub ai_chunks: u64, + pub ai_enabled: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DashboardPostItem { + pub id: i32, + pub title: String, + pub slug: String, + pub category: String, + pub post_type: String, + pub pinned: bool, + pub status: String, + pub visibility: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DashboardCommentItem { + pub id: i32, + pub author: String, + pub post_slug: String, + pub scope: String, + pub excerpt: String, + pub approved: bool, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DashboardFriendLinkItem { + pub id: i32, + pub site_name: String, + pub site_url: String, + pub category: String, + pub status: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DashboardReviewItem { + pub id: i32, + pub title: String, + pub review_type: String, + pub rating: i32, + pub status: String, + pub review_date: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DashboardSiteSummary { + pub site_name: String, + pub site_url: String, + pub ai_enabled: bool, + pub ai_chunks: u64, + pub ai_last_indexed_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminDashboardResponse { + pub stats: DashboardStats, + pub site: DashboardSiteSummary, + pub recent_posts: Vec, + pub pending_comments: Vec, + pub pending_friend_links: Vec, + pub recent_reviews: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminSiteSettingsResponse { + pub id: i32, + pub site_name: Option, + pub site_short_name: Option, + pub site_url: Option, + pub site_title: Option, + pub site_description: Option, + pub hero_title: Option, + pub hero_subtitle: Option, + pub owner_name: Option, + pub owner_title: Option, + pub owner_bio: Option, + pub owner_avatar_url: Option, + pub social_github: Option, + pub social_twitter: Option, + pub social_email: Option, + pub location: Option, + pub tech_stack: Vec, + pub music_playlist: Vec, + pub ai_enabled: bool, + pub paragraph_comments_enabled: bool, + pub ai_provider: Option, + pub ai_api_base: Option, + pub ai_api_key: Option, + pub ai_chat_model: Option, + pub ai_image_provider: Option, + pub ai_image_api_base: Option, + pub ai_image_api_key: Option, + pub ai_image_model: Option, + pub ai_providers: Vec, + pub ai_active_provider_id: Option, + pub ai_embedding_model: Option, + pub ai_system_prompt: Option, + pub ai_top_k: Option, + pub ai_chunk_size: Option, + pub ai_last_indexed_at: Option, + pub ai_chunks_count: u64, + pub ai_local_embedding: String, + pub media_storage_provider: Option, + pub media_r2_account_id: Option, + pub media_r2_bucket: Option, + pub media_r2_public_base_url: Option, + pub media_r2_access_key_id: Option, + pub media_r2_secret_access_key: Option, + pub seo_default_og_image: Option, + pub seo_default_twitter_handle: Option, + pub notification_webhook_url: Option, + pub notification_comment_enabled: bool, + pub notification_friend_link_enabled: bool, + pub search_synonyms: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminAiReindexResponse { + pub indexed_chunks: usize, + pub last_indexed_at: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminAiProviderTestRequest { + pub provider: site_settings::AiProviderConfig, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminAiProviderTestResponse { + pub provider: String, + pub endpoint: String, + pub chat_model: String, + pub reply_preview: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminAiImageProviderTestRequest { + pub provider: String, + pub api_base: String, + pub api_key: String, + pub image_model: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminAiImageProviderTestResponse { + pub provider: String, + pub endpoint: String, + pub image_model: String, + pub result_preview: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminImageUploadResponse { + pub url: String, + pub key: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminR2ConnectivityResponse { + pub bucket: String, + pub public_base_url: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminMediaObjectResponse { + pub key: String, + pub url: String, + pub size_bytes: i64, + pub last_modified: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminMediaListResponse { + pub provider: String, + pub bucket: String, + pub public_base_url: String, + pub items: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminMediaDeleteResponse { + pub deleted: bool, + pub key: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminMediaUploadItem { + pub key: String, + pub url: String, + pub size_bytes: i64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminMediaUploadResponse { + pub uploaded: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminMediaBatchDeleteRequest { + #[serde(default)] + pub keys: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminMediaBatchDeleteResponse { + pub deleted: Vec, + pub failed: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminMediaReplaceResponse { + pub key: String, + pub url: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminMediaListQuery { + pub prefix: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminMediaDeleteQuery { + pub key: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminCommentBlacklistItem { + pub id: i32, + pub matcher_type: String, + pub matcher_value: String, + pub reason: Option, + pub active: bool, + pub expires_at: Option, + pub created_at: String, + pub updated_at: String, + pub effective: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminCommentBlacklistCreateRequest { + pub matcher_type: String, + pub matcher_value: String, + pub reason: Option, + pub active: Option, + pub expires_at: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminCommentBlacklistUpdateRequest { + pub reason: Option, + pub active: Option, + pub expires_at: Option, + #[serde(default)] + pub clear_expires_at: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminCommentBlacklistDeleteResponse { + pub deleted: bool, + pub id: i32, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminCommentAnalyzeRequest { + pub matcher_type: String, + pub matcher_value: String, + pub from: Option, + pub to: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminCommentAnalyzeLogsQuery { + pub matcher_type: Option, + pub matcher_value: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AdminCommentAnalyzeSample { + pub id: i32, + pub created_at: String, + pub post_slug: String, + pub author: String, + pub email: String, + pub approved: bool, + pub content_preview: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminCommentAnalyzeResponse { + pub matcher_type: String, + pub matcher_value: String, + pub total_comments: u64, + pub pending_comments: u64, + pub first_seen_at: Option, + pub latest_seen_at: Option, + pub distinct_posts: usize, + pub analysis: String, + pub samples: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminCommentAnalyzeLogItem { + pub id: i32, + pub matcher_type: String, + pub matcher_value: String, + pub from_at: Option, + pub to_at: Option, + pub total_comments: u64, + pub pending_comments: u64, + pub distinct_posts: usize, + pub analysis: String, + pub samples: Vec, + pub created_at: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminPostMetadataRequest { + pub markdown: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminPostPolishRequest { + pub markdown: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminReviewPolishRequest { + pub title: String, + pub review_type: String, + pub rating: i32, + pub review_date: Option, + pub status: String, + #[serde(default)] + pub tags: Vec, + pub description: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AdminPostCoverImageRequest { + pub title: String, + pub description: Option, + pub category: Option, + #[serde(default)] + pub tags: Vec, + pub post_type: String, + pub slug: Option, + pub markdown: String, +} + +fn format_timestamp( + value: Option, + pattern: &str, +) -> Option { + value.map(|item| item.format(pattern).to_string()) +} + +fn required_text(value: Option<&str>, fallback: &str) -> String { + value + .map(str::trim) + .filter(|item| !item.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn parse_optional_timestamp( + value: Option<&str>, +) -> Result>> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + + chrono::DateTime::parse_from_rfc3339(value) + .map(Some) + .map_err(|_| Error::BadRequest("expires_at 必须是 RFC3339 时间格式".to_string())) +} + +fn parse_optional_datetime_utc( + value: Option<&str>, +) -> Result>> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + + if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(value) { + return Ok(Some(parsed.with_timezone(&chrono::Utc))); + } + + if let Ok(date_only) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") { + let Some(naive) = date_only.and_hms_opt(0, 0, 0) else { + return Ok(None); + }; + return Ok(Some( + chrono::DateTime::::from_naive_utc_and_offset(naive, chrono::Utc), + )); + } + + Err(Error::BadRequest( + "from/to 必须是 RFC3339 或 YYYY-MM-DD 格式".to_string(), + )) +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let trimmed = value.trim(); + if trimmed.chars().count() <= max_chars { + return trimmed.to_string(); + } + + let mut sliced = trimmed.chars().take(max_chars).collect::(); + sliced.push_str("..."); + sliced +} + +async fn save_comment_persona_analysis_log( + ctx: &AppContext, + matcher_type: &str, + matcher_value: &str, + from: Option>, + to: Option>, + total_comments: u64, + pending_comments: u64, + distinct_posts: usize, + analysis_text: &str, + samples: &[AdminCommentAnalyzeSample], +) -> Result<()> { + let sample_json = serde_json::to_value(samples).ok(); + + comment_persona_analysis_logs::ActiveModel { + matcher_type: Set(matcher_type.to_string()), + matcher_value: Set(matcher_value.to_string()), + from_at: Set(from.map(|value| value.fixed_offset())), + to_at: Set(to.map(|value| value.fixed_offset())), + total_comments: Set(total_comments.min(i32::MAX as u64) as i32), + pending_comments: Set(pending_comments.min(i32::MAX as u64) as i32), + distinct_posts: Set(distinct_posts.min(i32::MAX as usize) as i32), + analysis_text: Set(analysis_text.to_string()), + sample_json: Set(sample_json), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + Ok(()) +} + +fn format_comment_analyze_log_item( + item: comment_persona_analysis_logs::Model, +) -> AdminCommentAnalyzeLogItem { + let samples = item + .sample_json + .clone() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default(); + + AdminCommentAnalyzeLogItem { + id: item.id, + matcher_type: item.matcher_type, + matcher_value: item.matcher_value, + from_at: format_timestamp(item.from_at, "%Y-%m-%d %H:%M:%S UTC"), + to_at: format_timestamp(item.to_at, "%Y-%m-%d %H:%M:%S UTC"), + total_comments: item.total_comments.max(0) as u64, + pending_comments: item.pending_comments.max(0) as u64, + distinct_posts: item.distinct_posts.max(0) as usize, + analysis: item.analysis_text, + samples, + created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + } +} + +fn format_comment_blacklist_item(item: comment_blacklist::Model) -> AdminCommentBlacklistItem { + let now = chrono::Utc::now(); + let active = item.active.unwrap_or(true); + let not_expired = item + .expires_at + .map(|value| chrono::DateTime::::from(value) > now) + .unwrap_or(true); + + AdminCommentBlacklistItem { + id: item.id, + matcher_type: item.matcher_type, + matcher_value: item.matcher_value, + reason: item.reason, + active, + expires_at: format_timestamp(item.expires_at, "%Y-%m-%d %H:%M:%S UTC"), + created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + updated_at: item.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), + effective: active && not_expired, + } +} + +fn infer_media_extension(file_name: Option<&str>, content_type: Option<&str>) -> String { + let from_name = file_name + .and_then(|name| name.rsplit('.').next()) + .map(str::trim) + .filter(|ext| !ext.is_empty()) + .map(str::to_ascii_lowercase); + + if let Some(ext) = from_name + .as_deref() + .filter(|ext| ext.chars().all(|ch| ch.is_ascii_alphanumeric()) && ext.len() <= 10) + { + return ext.to_string(); + } + + match content_type + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "image/png" => "png".to_string(), + "image/jpeg" => "jpg".to_string(), + "image/webp" => "webp".to_string(), + "image/gif" => "gif".to_string(), + "image/avif" => "avif".to_string(), + "image/svg+xml" => "svg".to_string(), + "application/pdf" => "pdf".to_string(), + _ => "bin".to_string(), + } +} + +fn normalize_media_key(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim().trim_start_matches('/').to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn tech_stack_values(value: &Option) -> Vec { + value + .as_ref() + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| item.as_str().map(ToString::to_string)) + .collect() +} + +fn music_playlist_values( + value: &Option, +) -> Vec { + value + .as_ref() + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| serde_json::from_value::(item).ok()) + .filter(|item| !item.title.trim().is_empty() && !item.url.trim().is_empty()) + .collect() +} + +fn build_settings_response( + item: crate::models::_entities::site_settings::Model, + ai_chunks_count: u64, +) -> AdminSiteSettingsResponse { + let ai_providers = site_settings::ai_provider_configs(&item); + let ai_active_provider_id = site_settings::active_ai_provider_id(&item); + + AdminSiteSettingsResponse { + id: item.id, + site_name: item.site_name, + site_short_name: item.site_short_name, + site_url: item.site_url, + site_title: item.site_title, + site_description: item.site_description, + hero_title: item.hero_title, + hero_subtitle: item.hero_subtitle, + owner_name: item.owner_name, + owner_title: item.owner_title, + owner_bio: item.owner_bio, + owner_avatar_url: item.owner_avatar_url, + social_github: item.social_github, + social_twitter: item.social_twitter, + social_email: item.social_email, + location: item.location, + tech_stack: tech_stack_values(&item.tech_stack), + music_playlist: music_playlist_values(&item.music_playlist), + ai_enabled: item.ai_enabled.unwrap_or(false), + paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true), + ai_provider: item.ai_provider, + ai_api_base: item.ai_api_base, + ai_api_key: item.ai_api_key, + ai_chat_model: item.ai_chat_model, + ai_image_provider: item.ai_image_provider, + ai_image_api_base: item.ai_image_api_base, + ai_image_api_key: item.ai_image_api_key, + ai_image_model: item.ai_image_model, + ai_providers, + ai_active_provider_id, + ai_embedding_model: item.ai_embedding_model, + ai_system_prompt: item.ai_system_prompt, + ai_top_k: item.ai_top_k, + ai_chunk_size: item.ai_chunk_size, + ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"), + ai_chunks_count, + ai_local_embedding: ai::local_embedding_label().to_string(), + media_storage_provider: item.media_storage_provider, + media_r2_account_id: item.media_r2_account_id, + media_r2_bucket: item.media_r2_bucket, + media_r2_public_base_url: item.media_r2_public_base_url, + media_r2_access_key_id: item.media_r2_access_key_id, + media_r2_secret_access_key: item.media_r2_secret_access_key, + seo_default_og_image: item.seo_default_og_image, + seo_default_twitter_handle: item.seo_default_twitter_handle, + notification_webhook_url: item.notification_webhook_url, + notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false), + notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false), + search_synonyms: tech_stack_values(&item.search_synonyms), + } +} + +#[debug_handler] +pub async fn session_status(headers: HeaderMap) -> Result { + format::json(build_session_response(resolve_admin_identity(&headers))) +} + +#[debug_handler] +pub async fn session_login( + State(ctx): State, + Json(payload): Json, +) -> Result { + if !local_login_enabled() { + return unauthorized("Local admin login is disabled"); + } + + if !validate_admin_credentials(payload.username.trim(), payload.password.trim()) { + return unauthorized("Invalid credentials"); + } + + let (identity, _token, cookie) = start_local_session(&admin_username()); + admin_audit::log_event( + &ctx, + Some(&identity), + "admin.login", + "admin_session", + None, + Some(identity.username.clone()), + None, + ) + .await?; + let mut response = format::json(build_session_response(Some(identity.clone())))?; + response.headers_mut().append( + header::SET_COOKIE, + cookie + .parse() + .map_err(|error| Error::BadRequest(format!("invalid session cookie: {error}")))?, + ); + + Ok(response) +} + +#[debug_handler] +pub async fn session_logout(headers: HeaderMap, State(ctx): State) -> Result { + let before = resolve_admin_identity(&headers); + if matches!(before.as_ref().map(|item| item.source.as_str()), Some("local")) { + clear_local_session(&headers); + } + + if let Some(identity) = before.as_ref() { + admin_audit::log_event( + &ctx, + Some(identity), + "admin.logout", + "admin_session", + None, + identity.email.clone().or_else(|| Some(identity.username.clone())), + None, + ) + .await?; + } + + let after = resolve_admin_identity(&headers).filter(|item| item.source != "local"); + let mut response = format::json(build_session_response(after))?; + response.headers_mut().append( + header::SET_COOKIE, + clear_local_session_cookie() + .parse() + .map_err(|error| Error::BadRequest(format!("invalid logout cookie: {error}")))?, + ); + + Ok(response) +} + +#[debug_handler] +pub async fn dashboard(headers: HeaderMap, State(ctx): State) -> Result { + check_auth(&headers)?; + content::sync_markdown_posts(&ctx).await?; + + let all_posts = posts::Entity::find().all(&ctx.db).await?; + let total_posts = all_posts.len() as u64; + let total_comments = comments::Entity::find().count(&ctx.db).await?; + let pending_comments = comments::Entity::find() + .filter(comments::Column::Approved.eq(false)) + .count(&ctx.db) + .await?; + let total_categories = crate::models::_entities::categories::Entity::find() + .count(&ctx.db) + .await?; + let total_tags = crate::models::_entities::tags::Entity::find() + .count(&ctx.db) + .await?; + let total_reviews = reviews::Entity::find().count(&ctx.db).await?; + let total_links = friend_links::Entity::find().count(&ctx.db).await?; + let pending_links = friend_links::Entity::find() + .filter(friend_links::Column::Status.eq("pending")) + .count(&ctx.db) + .await?; + let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?; + let site_settings = site_settings::load_current(&ctx).await?; + let now = chrono::Utc::now().fixed_offset(); + + let mut draft_posts = 0_u64; + let mut scheduled_posts = 0_u64; + let mut offline_posts = 0_u64; + let mut expired_posts = 0_u64; + let mut private_posts = 0_u64; + let mut unlisted_posts = 0_u64; + + for post in &all_posts { + let effective_state = content::effective_post_state( + post.status + .as_deref() + .unwrap_or(content::POST_STATUS_PUBLISHED), + post.publish_at, + post.unpublish_at, + now, + ); + let visibility = content::normalize_post_visibility(post.visibility.as_deref()); + + match effective_state.as_str() { + content::POST_STATUS_DRAFT => draft_posts += 1, + content::POST_STATUS_OFFLINE => offline_posts += 1, + "scheduled" => scheduled_posts += 1, + "expired" => expired_posts += 1, + _ => {} + } + + match visibility.as_str() { + content::POST_VISIBILITY_PRIVATE => private_posts += 1, + content::POST_VISIBILITY_UNLISTED => unlisted_posts += 1, + _ => {} + } + } + + let mut recent_posts = all_posts + .clone() + .into_iter() + .collect::>(); + recent_posts.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + let recent_posts = recent_posts + .into_iter() + .take(6) + .map(|post| DashboardPostItem { + id: post.id, + title: required_text(post.title.as_deref(), "Untitled post"), + slug: post.slug, + category: required_text(post.category.as_deref(), "Uncategorized"), + post_type: required_text(post.post_type.as_deref(), "article"), + pinned: post.pinned.unwrap_or(false), + status: content::effective_post_state( + post.status + .as_deref() + .unwrap_or(content::POST_STATUS_PUBLISHED), + post.publish_at, + post.unpublish_at, + now, + ), + visibility: content::normalize_post_visibility(post.visibility.as_deref()), + created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(), + }) + .collect::>(); + + let pending_comment_rows = comments::Entity::find() + .filter(comments::Column::Approved.eq(false)) + .order_by_desc(comments::Column::CreatedAt) + .limit(8) + .all(&ctx.db) + .await? + .into_iter() + .map(|comment| DashboardCommentItem { + id: comment.id, + author: required_text(comment.author.as_deref(), "Anonymous"), + post_slug: required_text(comment.post_slug.as_deref(), "unknown-post"), + scope: required_text(Some(comment.scope.as_str()), "global"), + excerpt: required_text(comment.content.as_deref(), ""), + approved: comment.approved.unwrap_or(false), + created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(), + }) + .collect::>(); + + let pending_friend_links = friend_links::Entity::find() + .filter(friend_links::Column::Status.eq("pending")) + .order_by_desc(friend_links::Column::CreatedAt) + .limit(6) + .all(&ctx.db) + .await? + .into_iter() + .map(|link| DashboardFriendLinkItem { + id: link.id, + site_name: required_text(link.site_name.as_deref(), "Unnamed site"), + site_url: link.site_url, + category: required_text(link.category.as_deref(), "Other"), + status: required_text(link.status.as_deref(), "pending"), + created_at: link.created_at.format("%Y-%m-%d %H:%M").to_string(), + }) + .collect::>(); + + let recent_reviews = reviews::Entity::find() + .order_by_desc(reviews::Column::CreatedAt) + .limit(6) + .all(&ctx.db) + .await? + .into_iter() + .map(|review| DashboardReviewItem { + id: review.id, + title: required_text(review.title.as_deref(), "Untitled review"), + review_type: required_text(review.review_type.as_deref(), "game"), + rating: review.rating.unwrap_or(0), + status: required_text(review.status.as_deref(), "completed"), + review_date: required_text(review.review_date.as_deref(), ""), + }) + .collect::>(); + + format::json(AdminDashboardResponse { + stats: DashboardStats { + total_posts, + total_comments, + pending_comments, + draft_posts, + scheduled_posts, + offline_posts, + expired_posts, + private_posts, + unlisted_posts, + total_categories, + total_tags, + total_reviews, + total_links, + pending_links, + ai_chunks: ai_chunks_count, + ai_enabled: site_settings.ai_enabled.unwrap_or(false), + }, + site: DashboardSiteSummary { + site_name: required_text(site_settings.site_name.as_deref(), "Unnamed site"), + site_url: required_text(site_settings.site_url.as_deref(), ""), + ai_enabled: site_settings.ai_enabled.unwrap_or(false), + ai_chunks: ai_chunks_count, + ai_last_indexed_at: format_timestamp( + site_settings.ai_last_indexed_at, + "%Y-%m-%d %H:%M:%S UTC", + ), + }, + recent_posts, + pending_comments: pending_comment_rows, + pending_friend_links, + recent_reviews, + }) +} + +#[debug_handler] +pub async fn analytics_overview(headers: HeaderMap, State(ctx): State) -> Result { + check_auth(&headers)?; + format::json(analytics::build_admin_analytics(&ctx).await?) +} + +#[debug_handler] +pub async fn get_site_settings(headers: HeaderMap, State(ctx): State) -> Result { + check_auth(&headers)?; + let current = site_settings::load_current(&ctx).await?; + let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?; + format::json(build_settings_response(current, ai_chunks_count)) +} + +#[debug_handler] +pub async fn update_site_settings( + headers: HeaderMap, + State(ctx): State, + Json(params): Json, +) -> Result { + let actor = check_auth(&headers)?; + + let current = site_settings::load_current(&ctx).await?; + let mut item = current; + params.apply(&mut item); + let item = item.into_active_model().reset_all(); + let updated = item.update(&ctx.db).await?; + let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "site_settings.update", + "site_settings", + Some(updated.id.to_string()), + updated.site_name.clone(), + None, + ) + .await?; + + format::json(build_settings_response(updated, ai_chunks_count)) +} + +#[debug_handler] +pub async fn reindex_ai(headers: HeaderMap, State(ctx): State) -> Result { + check_auth(&headers)?; + let summary = ai::rebuild_index(&ctx).await?; + + format::json(AdminAiReindexResponse { + indexed_chunks: summary.indexed_chunks, + last_indexed_at: format_timestamp( + summary.last_indexed_at.map(Into::into), + "%Y-%m-%d %H:%M:%S UTC", + ), + }) +} + +#[debug_handler] +pub async fn test_ai_provider( + headers: HeaderMap, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + + let result = ai::test_provider_connectivity( + &payload.provider.provider, + payload.provider.api_base.as_deref().unwrap_or_default(), + payload.provider.api_key.as_deref().unwrap_or_default(), + payload.provider.chat_model.as_deref().unwrap_or_default(), + ) + .await?; + + format::json(AdminAiProviderTestResponse { + provider: result.provider, + endpoint: result.endpoint, + chat_model: result.chat_model, + reply_preview: result.reply_preview, + }) +} + +#[debug_handler] +pub async fn test_ai_image_provider( + headers: HeaderMap, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + + let result = ai::test_image_provider_connectivity( + &payload.provider, + &payload.api_base, + &payload.api_key, + &payload.image_model, + ) + .await?; + + format::json(AdminAiImageProviderTestResponse { + provider: result.provider, + endpoint: result.endpoint, + image_model: result.image_model, + result_preview: result.result_preview, + }) +} + +#[debug_handler] +pub async fn test_r2_storage(headers: HeaderMap, State(ctx): State) -> Result { + check_auth(&headers)?; + + let settings = storage::require_r2_settings(&ctx).await?; + let bucket = storage::test_r2_connectivity(&ctx).await?; + + format::json(AdminR2ConnectivityResponse { + bucket, + public_base_url: settings.public_base_url, + }) +} + +#[debug_handler] +pub async fn list_media_objects( + headers: HeaderMap, + State(ctx): State, + Query(query): Query, +) -> Result { + check_auth(&headers)?; + + let settings = storage::require_r2_settings(&ctx).await?; + let items = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200)) + .await? + .into_iter() + .map(|item| AdminMediaObjectResponse { + key: item.key, + url: item.url, + size_bytes: item.size_bytes, + last_modified: item.last_modified, + }) + .collect::>(); + + format::json(AdminMediaListResponse { + provider: settings.provider_name, + bucket: settings.bucket, + public_base_url: settings.public_base_url, + items, + }) +} + +#[debug_handler] +pub async fn delete_media_object( + headers: HeaderMap, + State(ctx): State, + Query(query): Query, +) -> Result { + check_auth(&headers)?; + + let key = query.key.trim(); + if key.is_empty() { + return Err(Error::BadRequest("缺少对象 key".to_string())); + } + + storage::delete_object(&ctx, key).await?; + + format::json(AdminMediaDeleteResponse { + deleted: true, + key: key.to_string(), + }) +} + +#[debug_handler] +pub async fn upload_media_objects( + headers: HeaderMap, + State(ctx): State, + mut multipart: Multipart, +) -> Result { + check_auth(&headers)?; + + let mut prefix = "uploads".to_string(); + let mut uploaded = Vec::::new(); + + while let Some(field) = multipart + .next_field() + .await + .map_err(|error| Error::BadRequest(error.to_string()))? + { + let name = field.name().unwrap_or_default().to_string(); + if name == "prefix" { + if let Ok(value) = field.text().await { + if let Some(next_prefix) = trim_to_option(Some(value)) { + prefix = next_prefix.trim_matches('/').to_string(); + } + } + continue; + } + + let file_name = field.file_name().map(ToString::to_string); + let content_type = field.content_type().map(ToString::to_string); + let bytes = field + .bytes() + .await + .map_err(|error| Error::BadRequest(error.to_string()))?; + + if bytes.is_empty() { + continue; + } + + let extension = infer_media_extension(file_name.as_deref(), content_type.as_deref()); + let key = + storage::build_object_key(&prefix, file_name.as_deref().unwrap_or("asset"), &extension); + let stored = storage::upload_bytes_to_r2( + &ctx, + &key, + bytes.to_vec(), + content_type.as_deref(), + Some("public, max-age=31536000, immutable"), + ) + .await?; + + uploaded.push(AdminMediaUploadItem { + key: stored.key, + url: stored.url, + size_bytes: bytes.len() as i64, + }); + } + + if uploaded.is_empty() { + return Err(Error::BadRequest("请至少选择一个文件上传".to_string())); + } + + format::json(AdminMediaUploadResponse { uploaded }) +} + +#[debug_handler] +pub async fn batch_delete_media_objects( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + + let keys = payload + .keys + .into_iter() + .filter_map(|key| normalize_media_key(Some(key))) + .collect::>(); + + if keys.is_empty() { + return Err(Error::BadRequest("请至少传入一个对象 key".to_string())); + } + + let mut deleted = Vec::new(); + let mut failed = Vec::new(); + + for key in keys { + match storage::delete_object(&ctx, &key).await { + Ok(()) => deleted.push(key), + Err(_) => failed.push(key), + } + } + + format::json(AdminMediaBatchDeleteResponse { deleted, failed }) +} + +#[debug_handler] +pub async fn replace_media_object( + headers: HeaderMap, + State(ctx): State, + mut multipart: Multipart, +) -> Result { + check_auth(&headers)?; + + let mut key: Option = None; + let mut bytes: Option> = None; + let mut content_type: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|error| Error::BadRequest(error.to_string()))? + { + let name = field.name().unwrap_or_default().to_string(); + if name == "key" { + let text = field + .text() + .await + .map_err(|error| Error::BadRequest(error.to_string()))?; + key = normalize_media_key(Some(text)); + continue; + } + + if bytes.is_none() { + content_type = field.content_type().map(ToString::to_string); + bytes = Some( + field + .bytes() + .await + .map_err(|error| Error::BadRequest(error.to_string()))? + .to_vec(), + ); + } + } + + let key = key.ok_or_else(|| Error::BadRequest("缺少待替换对象 key".to_string()))?; + let bytes = bytes.ok_or_else(|| Error::BadRequest("请先选择替换文件".to_string()))?; + + if bytes.is_empty() { + return Err(Error::BadRequest("替换文件内容为空".to_string())); + } + + let stored = storage::upload_bytes_to_r2( + &ctx, + &key, + bytes, + content_type.as_deref(), + Some("public, max-age=31536000, immutable"), + ) + .await?; + + format::json(AdminMediaReplaceResponse { + key: stored.key, + url: stored.url, + }) +} + +#[debug_handler] +pub async fn list_comment_blacklist(headers: HeaderMap, State(ctx): State) -> Result { + check_auth(&headers)?; + + let items = comment_blacklist::Entity::find() + .order_by_desc(comment_blacklist::Column::CreatedAt) + .all(&ctx.db) + .await? + .into_iter() + .map(format_comment_blacklist_item) + .collect::>(); + + format::json(items) +} + +#[debug_handler] +pub async fn create_comment_blacklist( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + + let matcher_type = + comment_guard::normalize_matcher_type(&payload.matcher_type).ok_or_else(|| { + Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string()) + })?; + let matcher_value = + comment_guard::normalize_matcher_value(matcher_type, &payload.matcher_value) + .ok_or_else(|| Error::BadRequest("matcher_value 不能为空".to_string()))?; + let expires_at = parse_optional_timestamp(payload.expires_at.as_deref())?; + + let item = comment_blacklist::ActiveModel { + matcher_type: Set(matcher_type.to_string()), + matcher_value: Set(matcher_value), + reason: Set(trim_to_option(payload.reason)), + active: Set(Some(payload.active.unwrap_or(true))), + expires_at: Set(expires_at), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + format::json(format_comment_blacklist_item(item)) +} + +#[debug_handler] +pub async fn update_comment_blacklist( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + + let item = comment_blacklist::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + let mut item = item.into_active_model(); + + if let Some(reason) = payload.reason { + item.reason = Set(trim_to_option(Some(reason))); + } + + if let Some(active) = payload.active { + item.active = Set(Some(active)); + } + + if payload.clear_expires_at { + item.expires_at = Set(None); + } else if payload.expires_at.is_some() { + item.expires_at = Set(parse_optional_timestamp(payload.expires_at.as_deref())?); + } + + let updated = item.update(&ctx.db).await?; + format::json(format_comment_blacklist_item(updated)) +} + +#[debug_handler] +pub async fn delete_comment_blacklist( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + check_auth(&headers)?; + + if let Some(item) = comment_blacklist::Entity::find_by_id(id) + .one(&ctx.db) + .await? + { + item.delete(&ctx.db).await?; + } + + format::json(AdminCommentBlacklistDeleteResponse { deleted: true, id }) +} + +#[debug_handler] +pub async fn list_comment_persona_analysis_logs( + headers: HeaderMap, + State(ctx): State, + Query(query): Query, +) -> Result { + check_auth(&headers)?; + + let matcher_type = query + .matcher_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + comment_guard::normalize_matcher_type(value).ok_or_else(|| { + Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string()) + }) + }) + .transpose()?; + + let matcher_value = query + .matcher_value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + if let Some(matcher_type) = matcher_type { + comment_guard::normalize_matcher_value(matcher_type, value) + } else { + Some(value.to_string()) + } + }) + .flatten(); + + if query.matcher_value.is_some() && matcher_value.is_none() { + return Err(Error::BadRequest("matcher_value 不能为空".to_string())); + } + + let mut query_builder = comment_persona_analysis_logs::Entity::find(); + + if let Some(matcher_type) = matcher_type { + query_builder = query_builder + .filter(comment_persona_analysis_logs::Column::MatcherType.eq(matcher_type)); + } + + if let Some(matcher_value) = matcher_value { + query_builder = query_builder + .filter(comment_persona_analysis_logs::Column::MatcherValue.eq(matcher_value)); + } + + let limit = query.limit.unwrap_or(20).clamp(1, 100); + + let items = query_builder + .order_by_desc(comment_persona_analysis_logs::Column::CreatedAt) + .limit(limit) + .all(&ctx.db) + .await? + .into_iter() + .map(format_comment_analyze_log_item) + .collect::>(); + + format::json(items) +} + +#[debug_handler] +pub async fn analyze_comment_persona( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + + let matcher_type = + comment_guard::normalize_matcher_type(&payload.matcher_type).ok_or_else(|| { + Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string()) + })?; + let matcher_value = + comment_guard::normalize_matcher_value(matcher_type, &payload.matcher_value) + .ok_or_else(|| Error::BadRequest("matcher_value 不能为空".to_string()))?; + let from = parse_optional_datetime_utc(payload.from.as_deref())?; + let to = parse_optional_datetime_utc(payload.to.as_deref())?; + let limit = payload.limit.unwrap_or(20).clamp(5, 80); + + let build_query = || { + let mut query = comments::Entity::find(); + + query = match matcher_type { + comment_guard::MATCHER_TYPE_IP => { + query.filter(comments::Column::IpAddress.eq(&matcher_value)) + } + comment_guard::MATCHER_TYPE_EMAIL => { + query.filter(comments::Column::Email.eq(&matcher_value)) + } + comment_guard::MATCHER_TYPE_USER_AGENT => { + query.filter(comments::Column::UserAgent.eq(&matcher_value)) + } + _ => query, + }; + + if let Some(from) = from { + query = query.filter(comments::Column::CreatedAt.gte(from)); + } + if let Some(to) = to { + query = query.filter(comments::Column::CreatedAt.lte(to)); + } + + query + }; + + let total_comments = build_query().count(&ctx.db).await?; + if total_comments == 0 { + let analysis = "当前条件下没有匹配评论,无法生成画像。".to_string(); + save_comment_persona_analysis_log( + &ctx, + matcher_type, + &matcher_value, + from, + to, + 0, + 0, + 0, + &analysis, + &[], + ) + .await?; + + return format::json(AdminCommentAnalyzeResponse { + matcher_type: matcher_type.to_string(), + matcher_value, + total_comments: 0, + pending_comments: 0, + first_seen_at: None, + latest_seen_at: None, + distinct_posts: 0, + analysis, + samples: Vec::new(), + }); + } + + let pending_comments = build_query() + .filter(comments::Column::Approved.eq(false)) + .count(&ctx.db) + .await?; + + let first_item = build_query() + .order_by_asc(comments::Column::CreatedAt) + .one(&ctx.db) + .await?; + let latest_item = build_query() + .order_by_desc(comments::Column::CreatedAt) + .one(&ctx.db) + .await?; + + let distinct_posts = build_query() + .select_only() + .column(comments::Column::PostSlug) + .distinct() + .into_tuple::>() + .all(&ctx.db) + .await? + .into_iter() + .filter_map(|item| item.map(|value| value.trim().to_string())) + .filter(|value| !value.is_empty()) + .collect::>() + .len(); + + let sample_rows = build_query() + .order_by_desc(comments::Column::CreatedAt) + .limit(limit) + .all(&ctx.db) + .await?; + + let samples = sample_rows + .iter() + .map(|item| AdminCommentAnalyzeSample { + id: item.id, + created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + post_slug: required_text(item.post_slug.as_deref(), "unknown-post"), + author: required_text(item.author.as_deref(), "匿名"), + email: required_text(item.email.as_deref(), ""), + approved: item.approved.unwrap_or(false), + content_preview: truncate_chars(item.content.as_deref().unwrap_or_default(), 220), + }) + .collect::>(); + + let sample_text = samples + .iter() + .map(|item| { + format!( + "- [{}] {} | post={} | author={} | status={} | content={}", + item.id, + item.created_at, + item.post_slug, + item.author, + if item.approved { "approved" } else { "pending" }, + item.content_preview + ) + }) + .collect::>() + .join("\n"); + + let analysis = ai::admin_chat_completion( + &ctx, + "你是博客评论风控分析助手。请输出中文,先结论后细节,不要编造。", + &format!( + "请基于以下评论画像数据,输出:\n\ +1) 风险等级(低/中/高)和理由;\n\ +2) 行为特征总结;\n\ +3) 建议动作(通过/观察/限速/临时封禁/永久封禁)及理由;\n\ +4) 误伤风险提示。\n\n\ +画像维度: type={matcher_type}, value={matcher_value}\n\ +评论总数: {total_comments}\n\ +待审核数: {pending_comments}\n\ +涉及文章数: {distinct_posts}\n\ +时间范围: from={} to={}\n\ +样本:\n{}", + payload.from.as_deref().unwrap_or("-"), + payload.to.as_deref().unwrap_or("-"), + sample_text + ), + ) + .await?; + + save_comment_persona_analysis_log( + &ctx, + matcher_type, + &matcher_value, + from, + to, + total_comments, + pending_comments, + distinct_posts, + &analysis, + &samples, + ) + .await?; + + format::json(AdminCommentAnalyzeResponse { + matcher_type: matcher_type.to_string(), + matcher_value, + total_comments, + pending_comments, + first_seen_at: first_item + .map(|item| item.created_at.format("%Y-%m-%d %H:%M:%S").to_string()), + latest_seen_at: latest_item + .map(|item| item.created_at.format("%Y-%m-%d %H:%M:%S").to_string()), + distinct_posts, + analysis, + samples, + }) +} + +#[debug_handler] +pub async fn generate_post_metadata( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + format::json(ai::generate_post_metadata(&ctx, &payload.markdown).await?) +} + +#[debug_handler] +pub async fn polish_post_markdown( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?) +} + +#[debug_handler] +pub async fn polish_review_description( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + format::json( + ai::polish_review_description( + &ctx, + &payload.title, + &payload.review_type, + payload.rating, + payload.review_date.as_deref(), + &payload.status, + &payload.tags, + &payload.description, + ) + .await?, + ) +} + +#[debug_handler] +pub async fn generate_post_cover_image( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + check_auth(&headers)?; + format::json( + ai::generate_post_cover_image( + &ctx, + &payload.title, + payload.description.as_deref(), + payload.category.as_deref(), + &payload.tags, + &payload.post_type, + payload.slug.as_deref(), + &payload.markdown, + ) + .await?, + ) +} + +fn review_cover_extension( + file_name: Option<&str>, + content_type: Option<&str>, +) -> Option<&'static str> { + let from_file_name = file_name + .and_then(|name| name.rsplit('.').next()) + .map(|ext| ext.trim().to_ascii_lowercase()); + + match from_file_name.as_deref() { + Some("png") => return Some("png"), + Some("jpg") | Some("jpeg") => return Some("jpg"), + Some("webp") => return Some("webp"), + Some("gif") => return Some("gif"), + Some("avif") => return Some("avif"), + Some("svg") => return Some("svg"), + _ => {} + } + + match content_type + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/webp" => Some("webp"), + "image/gif" => Some("gif"), + "image/avif" => Some("avif"), + "image/svg+xml" => Some("svg"), + _ => None, + } +} + +#[debug_handler] +pub async fn upload_review_cover_image( + headers: HeaderMap, + State(ctx): State, + mut multipart: Multipart, +) -> Result { + check_auth(&headers)?; + + let field = multipart + .next_field() + .await + .map_err(|error| Error::BadRequest(error.to_string()))? + .ok_or_else(|| Error::BadRequest("请先选择图片文件".to_string()))?; + let file_name = field.file_name().map(ToString::to_string); + let content_type = field.content_type().map(ToString::to_string); + let extension = review_cover_extension(file_name.as_deref(), content_type.as_deref()) + .ok_or_else(|| Error::BadRequest("仅支持常见图片格式上传".to_string()))?; + let bytes = field + .bytes() + .await + .map_err(|error| Error::BadRequest(error.to_string()))?; + + if bytes.is_empty() { + return Err(Error::BadRequest("上传的图片内容为空".to_string())); + } + + let key = crate::services::storage::build_object_key( + "review-covers", + file_name.as_deref().unwrap_or("review-cover"), + extension, + ); + let stored = crate::services::storage::upload_bytes_to_r2( + &ctx, + &key, + bytes.to_vec(), + content_type.as_deref(), + Some("public, max-age=31536000, immutable"), + ) + .await?; + + format::json(AdminImageUploadResponse { + url: stored.url, + key: stored.key, + }) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("/api/admin") + .add("/session", get(session_status)) + .add("/session", delete(session_logout)) + .add("/session/login", post(session_login)) + .add("/dashboard", get(dashboard)) + .add("/analytics", get(analytics_overview)) + .add("/site-settings", get(get_site_settings)) + .add("/site-settings", patch(update_site_settings)) + .add("/site-settings", put(update_site_settings)) + .add("/ai/reindex", post(reindex_ai)) + .add("/ai/test-provider", post(test_ai_provider)) + .add("/ai/test-image-provider", post(test_ai_image_provider)) + .add("/storage/r2/test", post(test_r2_storage)) + .add( + "/storage/media", + get(list_media_objects) + .post(upload_media_objects) + .delete(delete_media_object), + ) + .add( + "/storage/media/batch-delete", + post(batch_delete_media_objects), + ) + .add("/storage/media/replace", post(replace_media_object)) + .add( + "/comments/blacklist", + get(list_comment_blacklist).post(create_comment_blacklist), + ) + .add( + "/comments/blacklist/{id}", + patch(update_comment_blacklist).delete(delete_comment_blacklist), + ) + .add( + "/comments/analyze/logs", + get(list_comment_persona_analysis_logs), + ) + .add("/comments/analyze", post(analyze_comment_persona)) + .add("/ai/post-metadata", post(generate_post_metadata)) + .add("/ai/polish-post", post(polish_post_markdown)) + .add("/ai/polish-review", post(polish_review_description)) + .add("/ai/post-cover", post(generate_post_cover_image)) + .add("/storage/review-cover", post(upload_review_cover_image)) +} diff --git a/backend/src/controllers/admin_ops.rs b/backend/src/controllers/admin_ops.rs new file mode 100644 index 0000000..f3c9d92 --- /dev/null +++ b/backend/src/controllers/admin_ops.rs @@ -0,0 +1,455 @@ +use axum::http::HeaderMap; +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, Order, QueryFilter, QueryOrder, + QuerySelect, Set, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + controllers::admin::check_auth, + models::_entities::{ + admin_audit_logs, notification_deliveries, post_revisions, subscriptions, + }, + services::{admin_audit, post_revisions as revision_service, subscriptions as subscription_service}, +}; + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct AuditLogQuery { + pub action: Option, + pub target_type: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct RevisionQuery { + pub slug: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct DeliveriesQuery { + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SubscriptionPayload { + #[serde(alias = "channelType")] + pub channel_type: String, + pub target: String, + #[serde(default, alias = "displayName")] + pub display_name: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub filters: Option, + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub secret: Option, + #[serde(default)] + pub notes: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SubscriptionUpdatePayload { + #[serde(default, alias = "channelType")] + pub channel_type: Option, + #[serde(default)] + pub target: Option, + #[serde(default, alias = "displayName")] + pub display_name: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub filters: Option, + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub secret: Option, + #[serde(default)] + pub notes: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct RestoreRevisionRequest { + #[serde(default)] + pub mode: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct DigestDispatchRequest { + pub period: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PostRevisionListItem { + pub id: i32, + pub post_slug: String, + pub post_title: Option, + pub operation: String, + pub revision_reason: Option, + pub actor_username: Option, + pub actor_email: Option, + pub actor_source: Option, + pub created_at: String, + pub has_markdown: bool, + pub metadata: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PostRevisionDetailResponse { + #[serde(flatten)] + pub item: PostRevisionListItem, + pub markdown: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RestoreRevisionResponse { + pub restored: bool, + pub revision_id: i32, + pub post_slug: String, + pub mode: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct SubscriptionListResponse { + pub subscriptions: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DeliveryListResponse { + pub deliveries: Vec, +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn format_revision(item: post_revisions::Model) -> PostRevisionListItem { + PostRevisionListItem { + id: item.id, + post_slug: item.post_slug, + post_title: item.post_title, + operation: item.operation, + revision_reason: item.revision_reason, + actor_username: item.actor_username, + actor_email: item.actor_email, + actor_source: item.actor_source, + created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + has_markdown: item.markdown.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some(), + metadata: item.metadata, + } +} + +#[debug_handler] +pub async fn list_audit_logs( + headers: HeaderMap, + Query(query): Query, + State(ctx): State, +) -> Result { + check_auth(&headers)?; + + let mut db_query = admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc); + + if let Some(action) = query.action.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { + db_query = db_query.filter(admin_audit_logs::Column::Action.eq(action)); + } + + if let Some(target_type) = query.target_type.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { + db_query = db_query.filter(admin_audit_logs::Column::TargetType.eq(target_type)); + } + + format::json(db_query.limit(query.limit.unwrap_or(80)).all(&ctx.db).await?) +} + +#[debug_handler] +pub async fn list_post_revisions( + headers: HeaderMap, + Query(query): Query, + State(ctx): State, +) -> Result { + check_auth(&headers)?; + let items = revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120)).await?; + format::json(items.into_iter().map(format_revision).collect::>()) +} + +#[debug_handler] +pub async fn get_post_revision( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + check_auth(&headers)?; + let item = revision_service::get_revision(&ctx, id).await?; + format::json(PostRevisionDetailResponse { + item: format_revision(item.clone()), + markdown: item.markdown, + }) +} + +#[debug_handler] +pub async fn restore_post_revision( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, + Json(payload): Json, +) -> Result { + let actor = check_auth(&headers)?; + let mode = payload.mode.unwrap_or_else(|| "full".to_string()); + let restored = + revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "post.revision.restore", + "post_revision", + Some(restored.id.to_string()), + Some(restored.post_slug.clone()), + Some(serde_json::json!({ + "post_slug": restored.post_slug, + "source_revision_id": id, + "mode": mode, + })), + ) + .await?; + + format::json(RestoreRevisionResponse { + restored: true, + revision_id: id, + post_slug: restored.post_slug, + mode, + }) +} + +#[debug_handler] +pub async fn list_subscriptions( + headers: HeaderMap, + State(ctx): State, +) -> Result { + check_auth(&headers)?; + format::json(SubscriptionListResponse { + subscriptions: subscription_service::list_subscriptions(&ctx, None, None).await?, + }) +} + +#[debug_handler] +pub async fn list_subscription_deliveries( + headers: HeaderMap, + Query(query): Query, + State(ctx): State, +) -> Result { + check_auth(&headers)?; + format::json(DeliveryListResponse { + deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80)).await?, + }) +} + +#[debug_handler] +pub async fn create_subscription( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + let actor = check_auth(&headers)?; + + let channel_type = subscription_service::normalize_channel_type(&payload.channel_type); + let target = payload.target.trim().to_string(); + if target.is_empty() { + return Err(Error::BadRequest("target 不能为空".to_string())); + } + + let created = subscriptions::ActiveModel { + channel_type: Set(channel_type.clone()), + target: Set(target.clone()), + display_name: Set(trim_to_option(payload.display_name)), + status: Set(subscription_service::normalize_status(payload.status.as_deref().unwrap_or("active"))), + filters: Set(subscription_service::normalize_filters(payload.filters)), + metadata: Set(payload.metadata), + secret: Set(trim_to_option(payload.secret)), + notes: Set(trim_to_option(payload.notes)), + confirm_token: Set(None), + manage_token: Set(Some(subscription_service::generate_subscription_token())), + verified_at: Set(Some(chrono::Utc::now().to_rfc3339())), + failure_count: Set(Some(0)), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + admin_audit::log_event( + &ctx, + Some(&actor), + "subscription.create", + "subscription", + Some(created.id.to_string()), + Some(format!("{}:{}", created.channel_type, created.target)), + Some(serde_json::json!({ "channel_type": created.channel_type, "target": created.target })), + ) + .await?; + + format::json(created) +} + +#[debug_handler] +pub async fn update_subscription( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, + Json(payload): Json, +) -> Result { + let actor = check_auth(&headers)?; + + let item = subscriptions::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + let mut active = item.clone().into_active_model(); + + if let Some(channel_type) = payload.channel_type { + active.channel_type = Set(subscription_service::normalize_channel_type(&channel_type)); + } + if let Some(target) = payload.target { + let normalized_target = target.trim().to_string(); + if normalized_target.is_empty() { + return Err(Error::BadRequest("target 不能为空".to_string())); + } + active.target = Set(normalized_target); + } + if payload.display_name.is_some() { + active.display_name = Set(trim_to_option(payload.display_name)); + } + if let Some(status) = payload.status { + active.status = Set(subscription_service::normalize_status(&status)); + } + if payload.filters.is_some() { + active.filters = Set(subscription_service::normalize_filters(payload.filters)); + } + if payload.metadata.is_some() { + active.metadata = Set(payload.metadata); + } + if payload.secret.is_some() { + active.secret = Set(trim_to_option(payload.secret)); + } + if payload.notes.is_some() { + active.notes = Set(trim_to_option(payload.notes)); + } + + let updated = active.update(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "subscription.update", + "subscription", + Some(updated.id.to_string()), + Some(format!("{}:{}", updated.channel_type, updated.target)), + None, + ) + .await?; + + format::json(updated) +} + +#[debug_handler] +pub async fn delete_subscription( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + let actor = check_auth(&headers)?; + let item = subscriptions::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + let label = format!("{}:{}", item.channel_type, item.target); + item.delete(&ctx.db).await?; + + admin_audit::log_event( + &ctx, + Some(&actor), + "subscription.delete", + "subscription", + Some(id.to_string()), + Some(label), + None, + ) + .await?; + + format::empty() +} + +#[debug_handler] +pub async fn test_subscription( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + let actor = check_auth(&headers)?; + let item = subscriptions::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + + let delivery = subscription_service::send_test_notification(&ctx, &item).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "subscription.test", + "subscription", + Some(item.id.to_string()), + Some(format!("{}:{}", item.channel_type, item.target)), + None, + ) + .await?; + + format::json(serde_json::json!({ "queued": true, "id": item.id, "delivery_id": delivery.id })) +} + +#[debug_handler] +pub async fn send_subscription_digest( + headers: HeaderMap, + State(ctx): State, + Json(payload): Json, +) -> Result { + let actor = check_auth(&headers)?; + let summary = subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly")).await?; + + admin_audit::log_event( + &ctx, + Some(&actor), + "subscription.digest.send", + "subscription_digest", + None, + Some(summary.period.clone()), + Some(serde_json::json!({ + "period": summary.period, + "post_count": summary.post_count, + "queued": summary.queued, + "skipped": summary.skipped, + })), + ) + .await?; + + format::json(summary) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("/api/admin") + .add("/audit-logs", get(list_audit_logs)) + .add("/post-revisions", get(list_post_revisions)) + .add("/post-revisions/{id}", get(get_post_revision)) + .add("/post-revisions/{id}/restore", post(restore_post_revision)) + .add("/subscriptions", get(list_subscriptions).post(create_subscription)) + .add("/subscriptions/deliveries", get(list_subscription_deliveries)) + .add("/subscriptions/digest", post(send_subscription_digest)) + .add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription)) + .add("/subscriptions/{id}/test", post(test_subscription)) +} diff --git a/backend/src/controllers/ai.rs b/backend/src/controllers/ai.rs new file mode 100644 index 0000000..f04c32a --- /dev/null +++ b/backend/src/controllers/ai.rs @@ -0,0 +1,532 @@ +#![allow(clippy::unused_async)] + +use async_stream::stream; +use axum::{ + body::{Body, Bytes}, + http::{ + header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, + HeaderMap, HeaderValue, + }, +}; +use chrono::{DateTime, Utc}; +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::time::Instant; + +use crate::{ + controllers::{admin::check_auth, site_settings}, + services::{abuse_guard, ai, analytics}, +}; + +#[derive(Clone, Debug, Deserialize)] +pub struct AskPayload { + pub question: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AskResponse { + pub question: String, + pub answer: String, + pub sources: Vec, + pub indexed_chunks: usize, + pub last_indexed_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ReindexResponse { + pub indexed_chunks: usize, + pub last_indexed_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct StreamStatusEvent { + phase: String, + message: String, +} + +#[derive(Clone, Debug, Serialize)] +struct StreamDeltaEvent { + delta: String, +} + +#[derive(Clone, Debug, Serialize)] +struct StreamErrorEvent { + message: String, +} + +fn format_timestamp(value: Option>) -> Option { + value.map(|item| item.to_rfc3339()) +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +async fn current_provider_metadata(ctx: &AppContext) -> (Option, Option) { + match site_settings::load_current(ctx).await { + Ok(settings) => ( + trim_to_option(settings.ai_provider), + trim_to_option(settings.ai_chat_model), + ), + Err(error) => { + tracing::warn!("failed to load ai provider metadata for analytics: {error}"); + (None, None) + } + } +} + +fn sse_bytes(event: &str, payload: &T) -> Bytes { + let data = serde_json::to_string(payload) + .unwrap_or_else(|_| "{\"message\":\"failed to serialize SSE payload\"}".to_string()); + + Bytes::from(format!("event: {event}\ndata: {data}\n\n")) +} + +fn take_next_sse_event(buffer: &mut String) -> Option<(Option, String)> { + let mut boundary = buffer.find("\n\n").map(|index| (index, 2)); + if let Some(index) = buffer.find("\r\n\r\n") { + match boundary { + Some((existing, _)) if existing <= index => {} + _ => boundary = Some((index, 4)), + } + } + + let (index, separator_len) = boundary?; + let raw = buffer[..index].to_string(); + buffer.drain(..index + separator_len); + + let normalized = raw.replace("\r\n", "\n"); + let mut event = None; + let mut data_lines = Vec::new(); + + for line in normalized.lines() { + if let Some(value) = line.strip_prefix("event:") { + event = Some(value.trim().to_string()); + continue; + } + + if let Some(value) = line.strip_prefix("data:") { + data_lines.push(value.trim_start().to_string()); + } + } + + Some((event, data_lines.join("\n"))) +} + +fn extract_stream_delta(value: &Value) -> Option { + if let Some(delta) = value.get("delta").and_then(Value::as_str) { + return Some(delta.to_string()); + } + + if let Some(content) = value + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("delta")) + .and_then(|delta| delta.get("content")) + { + if let Some(text) = content.as_str() { + return Some(text.to_string()); + } + + if let Some(parts) = content.as_array() { + let merged = parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(Value::as_str) + .or_else(|| part.as_str()) + }) + .collect::>() + .join(""); + + if !merged.is_empty() { + return Some(merged); + } + } + } + + value + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("text")) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn append_missing_suffix(accumulated: &mut String, full_text: &str) -> Option { + if full_text.is_empty() { + return None; + } + + if accumulated.is_empty() { + accumulated.push_str(full_text); + return Some(full_text.to_string()); + } + + if full_text.starts_with(accumulated.as_str()) { + let suffix = full_text[accumulated.len()..].to_string(); + if !suffix.is_empty() { + accumulated.push_str(&suffix); + return Some(suffix); + } + } + + None +} + +fn chunk_text(value: &str, chunk_size: usize) -> Vec { + let chars = value.chars().collect::>(); + chars + .chunks(chunk_size.max(1)) + .map(|chunk| chunk.iter().collect::()) + .filter(|chunk| !chunk.is_empty()) + .collect::>() +} + +fn build_ask_response(prepared: &ai::PreparedAiAnswer, answer: String) -> AskResponse { + AskResponse { + question: prepared.question.clone(), + answer, + sources: prepared.sources.clone(), + indexed_chunks: prepared.indexed_chunks, + last_indexed_at: format_timestamp(prepared.last_indexed_at), + } +} + +#[debug_handler] +pub async fn ask( + State(ctx): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + let started_at = Instant::now(); + let question = payload.question.trim().to_string(); + let (provider, chat_model) = current_provider_metadata(&ctx).await; + abuse_guard::enforce_public_scope( + "ai_ask", + abuse_guard::detect_client_ip(&headers).as_deref(), + Some(&question), + )?; + + match ai::answer_question(&ctx, &payload.question).await { + Ok(result) => { + analytics::record_ai_question_event( + &ctx, + &question, + &headers, + true, + "sync", + provider, + chat_model, + Some(result.sources.len()), + started_at.elapsed().as_millis() as i64, + ) + .await; + + format::json(AskResponse { + question, + answer: result.answer, + sources: result.sources, + indexed_chunks: result.indexed_chunks, + last_indexed_at: format_timestamp(result.last_indexed_at), + }) + } + Err(error) => { + analytics::record_ai_question_event( + &ctx, + &question, + &headers, + false, + "sync", + provider, + chat_model, + None, + started_at.elapsed().as_millis() as i64, + ) + .await; + Err(error) + } + } +} + +#[debug_handler] +pub async fn ask_stream( + State(ctx): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + let request_headers = headers.clone(); + let question = payload.question.trim().to_string(); + let (fallback_provider, fallback_chat_model) = current_provider_metadata(&ctx).await; + abuse_guard::enforce_public_scope( + "ai_stream", + abuse_guard::detect_client_ip(&headers).as_deref(), + Some(&question), + )?; + + let stream = stream! { + let started_at = Instant::now(); + yield Ok::(sse_bytes("status", &StreamStatusEvent { + phase: "retrieving".to_string(), + message: "正在检索知识库上下文...".to_string(), + })); + + let prepared = match ai::prepare_answer(&ctx, &payload.question).await { + Ok(prepared) => prepared, + Err(error) => { + analytics::record_ai_question_event( + &ctx, + &question, + &request_headers, + false, + "stream", + fallback_provider.clone(), + fallback_chat_model.clone(), + None, + started_at.elapsed().as_millis() as i64, + ) + .await; + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: error.to_string(), + })); + return; + } + }; + + let mut accumulated_answer = String::new(); + let active_provider = prepared + .provider_request + .as_ref() + .map(|request| request.provider.clone()) + .or_else(|| fallback_provider.clone()); + let active_chat_model = prepared + .provider_request + .as_ref() + .map(|request| request.chat_model.clone()) + .or_else(|| fallback_chat_model.clone()); + + if let Some(answer) = prepared.immediate_answer.as_deref() { + yield Ok(sse_bytes("status", &StreamStatusEvent { + phase: "answering".to_string(), + message: "已完成检索,正在输出检索结论...".to_string(), + })); + + for chunk in chunk_text(answer, 48) { + accumulated_answer.push_str(&chunk); + yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta: chunk })); + } + } else if let Some(provider_request) = prepared.provider_request.as_ref() { + yield Ok(sse_bytes("status", &StreamStatusEvent { + phase: "streaming".to_string(), + message: "已命中相关资料,正在流式生成回答...".to_string(), + })); + + let client = reqwest::Client::new(); + let response = client + .post(ai::build_provider_url(provider_request)) + .bearer_auth(&provider_request.api_key) + .header("Accept", "text/event-stream, application/json") + .json(&ai::build_provider_payload(provider_request, true)) + .send() + .await; + + let mut response = match response { + Ok(response) => response, + Err(error) => { + analytics::record_ai_question_event( + &ctx, + &question, + &request_headers, + false, + "stream", + active_provider.clone(), + active_chat_model.clone(), + Some(prepared.sources.len()), + started_at.elapsed().as_millis() as i64, + ) + .await; + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: format!("AI request failed: {error}"), + })); + return; + } + }; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + analytics::record_ai_question_event( + &ctx, + &question, + &request_headers, + false, + "stream", + active_provider.clone(), + active_chat_model.clone(), + Some(prepared.sources.len()), + started_at.elapsed().as_millis() as i64, + ) + .await; + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: format!("AI provider returned {status}: {body}"), + })); + return; + } + + let mut sse_buffer = String::new(); + let mut last_full_answer = None; + + loop { + let next_chunk = response.chunk().await; + let Some(chunk) = (match next_chunk { + Ok(chunk) => chunk, + Err(error) => { + analytics::record_ai_question_event( + &ctx, + &question, + &request_headers, + false, + "stream", + active_provider.clone(), + active_chat_model.clone(), + Some(prepared.sources.len()), + started_at.elapsed().as_millis() as i64, + ) + .await; + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: format!("AI stream read failed: {error}"), + })); + return; + } + }) else { + break; + }; + + sse_buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some((_event_name, data)) = take_next_sse_event(&mut sse_buffer) { + let trimmed = data.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed == "[DONE]" { + continue; + } + + let parsed = match serde_json::from_str::(trimmed) { + Ok(parsed) => parsed, + Err(_) => continue, + }; + + if let Some(full_text) = ai::extract_provider_text(&parsed) { + last_full_answer = Some(full_text); + } + + if let Some(delta) = extract_stream_delta(&parsed) { + accumulated_answer.push_str(&delta); + yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta })); + } + } + } + + let leftover = sse_buffer.trim(); + if !leftover.is_empty() && leftover != "[DONE]" { + if let Ok(parsed) = serde_json::from_str::(leftover) { + if let Some(full_text) = ai::extract_provider_text(&parsed) { + last_full_answer = Some(full_text); + } + + if let Some(delta) = extract_stream_delta(&parsed) { + accumulated_answer.push_str(&delta); + yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta })); + } + } + } + + if let Some(full_text) = last_full_answer { + if let Some(suffix) = append_missing_suffix(&mut accumulated_answer, &full_text) { + yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta: suffix })); + } + } + + if accumulated_answer.is_empty() { + analytics::record_ai_question_event( + &ctx, + &question, + &request_headers, + false, + "stream", + active_provider.clone(), + active_chat_model.clone(), + Some(prepared.sources.len()), + started_at.elapsed().as_millis() as i64, + ) + .await; + yield Ok(sse_bytes("error", &StreamErrorEvent { + message: "AI chat response did not contain readable content".to_string(), + })); + return; + } + } + + analytics::record_ai_question_event( + &ctx, + &question, + &request_headers, + true, + "stream", + active_provider, + active_chat_model, + Some(prepared.sources.len()), + started_at.elapsed().as_millis() as i64, + ) + .await; + + let final_payload = build_ask_response(&prepared, accumulated_answer); + yield Ok(sse_bytes("complete", &final_payload)); + }; + + let mut response = Response::new(Body::from_stream(stream)); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/event-stream; charset=utf-8"), + ); + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + response + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("keep-alive")); + + Ok(response) +} + +#[debug_handler] +pub async fn reindex(headers: HeaderMap, State(ctx): State) -> Result { + check_auth(&headers)?; + let summary = ai::rebuild_index(&ctx).await?; + + format::json(ReindexResponse { + indexed_chunks: summary.indexed_chunks, + last_indexed_at: format_timestamp(summary.last_indexed_at), + }) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/ai/") + .add("/ask", post(ask)) + .add("/ask/stream", post(ask_stream)) + .add("/reindex", post(reindex)) +} diff --git a/backend/src/controllers/category.rs b/backend/src/controllers/category.rs index 0d75f08..f14c37b 100644 --- a/backend/src/controllers/category.rs +++ b/backend/src/controllers/category.rs @@ -136,16 +136,36 @@ pub async fn update( let name = normalized_name(¶ms)?; let slug = normalized_slug(¶ms, &name); let item = load_item(&ctx, id).await?; + let previous_name = item.name.clone(); + let previous_slug = item.slug.clone(); + + if previous_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + != Some(name.as_str()) + { + content::rewrite_category_references( + previous_name.as_deref(), + &previous_slug, + Some(&name), + )?; + } + let mut item = item.into_active_model(); item.name = Set(Some(name)); item.slug = Set(slug); let item = item.update(&ctx.db).await?; + content::sync_markdown_posts(&ctx).await?; format::json(item) } #[debug_handler] pub async fn remove(Path(id): Path, State(ctx): State) -> Result { - load_item(&ctx, id).await?.delete(&ctx.db).await?; + let item = load_item(&ctx, id).await?; + content::rewrite_category_references(item.name.as_deref(), &item.slug, None)?; + item.delete(&ctx.db).await?; + content::sync_markdown_posts(&ctx).await?; format::empty() } diff --git a/backend/src/controllers/comment.rs b/backend/src/controllers/comment.rs index 5440792..22e1f18 100644 --- a/backend/src/controllers/comment.rs +++ b/backend/src/controllers/comment.rs @@ -4,11 +4,27 @@ use loco_rs::prelude::*; use sea_orm::{ColumnTrait, QueryFilter, QueryOrder}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::net::SocketAddr; + +use axum::{ + extract::{rejection::ExtensionRejection, ConnectInfo}, + http::{header, HeaderMap}, +}; use crate::models::_entities::{ comments::{ActiveModel, Column, Entity, Model}, posts, }; +use crate::services::{ + admin_audit, + comment_guard::{self, CommentGuardInput}, + notifications, +}; +use crate::controllers::admin::check_auth; + +const ARTICLE_SCOPE: &str = "article"; +const PARAGRAPH_SCOPE: &str = "paragraph"; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Params { @@ -19,6 +35,10 @@ pub struct Params { pub avatar: Option, pub content: Option, pub reply_to: Option, + pub reply_to_comment_id: Option, + pub scope: Option, + pub paragraph_key: Option, + pub paragraph_excerpt: Option, pub approved: Option, } @@ -45,6 +65,18 @@ impl Params { if let Some(reply_to) = self.reply_to { item.reply_to = Set(Some(reply_to)); } + if let Some(reply_to_comment_id) = self.reply_to_comment_id { + item.reply_to_comment_id = Set(Some(reply_to_comment_id)); + } + if let Some(scope) = &self.scope { + item.scope = Set(scope.clone()); + } + if let Some(paragraph_key) = &self.paragraph_key { + item.paragraph_key = Set(Some(paragraph_key.clone())); + } + if let Some(paragraph_excerpt) = &self.paragraph_excerpt { + item.paragraph_excerpt = Set(Some(paragraph_excerpt.clone())); + } if let Some(approved) = self.approved { item.approved = Set(Some(approved)); } @@ -55,6 +87,8 @@ impl Params { pub struct ListQuery { pub post_id: Option, pub post_slug: Option, + pub scope: Option, + pub paragraph_key: Option, pub approved: Option, } @@ -74,8 +108,106 @@ pub struct CreateCommentRequest { pub content: Option, #[serde(default, alias = "replyTo")] pub reply_to: Option, + #[serde(default, alias = "replyToCommentId")] + pub reply_to_comment_id: Option, + #[serde(default)] + pub scope: Option, + #[serde(default, alias = "paragraphKey")] + pub paragraph_key: Option, + #[serde(default, alias = "paragraphExcerpt")] + pub paragraph_excerpt: Option, #[serde(default)] pub approved: Option, + #[serde(default, alias = "captchaToken")] + pub captcha_token: Option, + #[serde(default, alias = "captchaAnswer")] + pub captcha_answer: Option, + #[serde(default)] + pub website: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ParagraphCommentSummary { + pub paragraph_key: String, + pub count: usize, +} + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalize_with_limit(value: Option<&str>, max_chars: usize) -> Option { + value.and_then(|item| { + let trimmed = item.trim(); + if trimmed.is_empty() { + return None; + } + + Some(trimmed.chars().take(max_chars).collect::()) + }) +} + +fn header_value<'a>(headers: &'a HeaderMap, key: header::HeaderName) -> Option<&'a str> { + headers.get(key).and_then(|value| value.to_str().ok()) +} + +fn first_forwarded_ip(value: &str) -> Option<&str> { + value + .split(',') + .map(str::trim) + .find(|item| !item.is_empty()) +} + +fn detect_client_ip( + headers: &HeaderMap, + connect_info: Option<&ConnectInfo>, +) -> Option { + let forwarded = header_value(headers, header::HeaderName::from_static("x-forwarded-for")) + .and_then(first_forwarded_ip); + let real_ip = header_value(headers, header::HeaderName::from_static("x-real-ip")); + let cf_connecting_ip = + header_value(headers, header::HeaderName::from_static("cf-connecting-ip")); + let true_client_ip = header_value(headers, header::HeaderName::from_static("true-client-ip")); + let remote_addr = connect_info.map(|addr| addr.0.ip().to_string()); + + normalize_with_limit( + forwarded + .or(real_ip) + .or(cf_connecting_ip) + .or(true_client_ip) + .or(remote_addr.as_deref()), + 96, + ) +} + +fn normalized_scope(value: Option) -> Result { + match value + .unwrap_or_else(|| ARTICLE_SCOPE.to_string()) + .trim() + .to_lowercase() + .as_str() + { + ARTICLE_SCOPE => Ok(ARTICLE_SCOPE.to_string()), + PARAGRAPH_SCOPE => Ok(PARAGRAPH_SCOPE.to_string()), + _ => Err(Error::BadRequest("invalid comment scope".to_string())), + } +} + +fn preview_excerpt(value: &str) -> Option { + let flattened = value.split_whitespace().collect::>().join(" "); + let excerpt = flattened.chars().take(120).collect::(); + if excerpt.is_empty() { + None + } else { + Some(excerpt) + } } async fn load_item(ctx: &AppContext, id: i32) -> Result { @@ -101,7 +233,12 @@ async fn resolve_post_slug(ctx: &AppContext, raw: &str) -> Result pub async fn list( Query(query): Query, State(ctx): State, + headers: HeaderMap, ) -> Result { + if query.approved != Some(true) { + check_auth(&headers)?; + } + let mut db_query = Entity::find().order_by_asc(Column::CreatedAt); let post_slug = if let Some(post_slug) = query.post_slug { @@ -116,6 +253,19 @@ pub async fn list( db_query = db_query.filter(Column::PostSlug.eq(post_slug)); } + if let Some(scope) = query.scope { + db_query = db_query.filter(Column::Scope.eq(scope.trim().to_lowercase())); + } + + if let Some(paragraph_key) = query + .paragraph_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + db_query = db_query.filter(Column::ParagraphKey.eq(paragraph_key)); + } + if let Some(approved) = query.approved { db_query = db_query.filter(Column::Approved.eq(approved)); } @@ -123,18 +273,121 @@ pub async fn list( format::json(db_query.all(&ctx.db).await?) } +#[debug_handler] +pub async fn paragraph_summary( + Query(query): Query, + State(ctx): State, +) -> Result { + let post_slug = if let Some(post_slug) = query.post_slug { + Some(post_slug) + } else if let Some(post_id) = query.post_id { + resolve_post_slug(&ctx, &post_id).await? + } else { + None + } + .ok_or_else(|| Error::BadRequest("post_slug is required".to_string()))?; + + let items = Entity::find() + .filter(Column::PostSlug.eq(post_slug)) + .filter(Column::Scope.eq(PARAGRAPH_SCOPE)) + .filter(Column::Approved.eq(true)) + .order_by_asc(Column::CreatedAt) + .all(&ctx.db) + .await?; + + let mut counts = BTreeMap::::new(); + for item in items { + let Some(paragraph_key) = item.paragraph_key.as_deref() else { + continue; + }; + let key = paragraph_key.trim(); + if key.is_empty() { + continue; + } + + *counts.entry(key.to_string()).or_default() += 1; + } + + let summary = counts + .into_iter() + .map(|(paragraph_key, count)| ParagraphCommentSummary { + paragraph_key, + count, + }) + .collect::>(); + + format::json(summary) +} + +#[debug_handler] +pub async fn captcha_challenge( + headers: HeaderMap, + connect_info: Result, ExtensionRejection>, +) -> Result { + let ip_address = detect_client_ip(&headers, connect_info.as_ref().ok()); + format::json(comment_guard::create_captcha_challenge( + ip_address.as_deref(), + )?) +} + #[debug_handler] pub async fn add( State(ctx): State, + headers: HeaderMap, + connect_info: Result, ExtensionRejection>, Json(params): Json, ) -> Result { + let scope = normalized_scope(params.scope.clone())?; let post_slug = if let Some(post_slug) = params.post_slug.as_deref() { Some(post_slug.to_string()) } else if let Some(post_id) = params.post_id.as_deref() { resolve_post_slug(&ctx, post_id).await? } else { None - }; + } + .and_then(|value| normalize_optional_string(Some(value))); + + let author = normalize_optional_string(params.author); + let email = normalize_optional_string(params.email); + let avatar = normalize_optional_string(params.avatar); + let content = normalize_optional_string(params.content); + let ip_address = detect_client_ip(&headers, connect_info.as_ref().ok()); + let user_agent = normalize_with_limit(header_value(&headers, header::USER_AGENT), 512); + let referer = normalize_with_limit(header_value(&headers, header::REFERER), 1024); + let paragraph_key = normalize_optional_string(params.paragraph_key); + let paragraph_excerpt = normalize_optional_string(params.paragraph_excerpt) + .or_else(|| content.as_deref().and_then(preview_excerpt)); + + if post_slug.is_none() { + return Err(Error::BadRequest("post_slug is required".to_string())); + } + + if author.is_none() { + return Err(Error::BadRequest("author is required".to_string())); + } + + if content.is_none() { + return Err(Error::BadRequest("content is required".to_string())); + } + + if scope == PARAGRAPH_SCOPE && paragraph_key.is_none() { + return Err(Error::BadRequest("paragraph_key is required".to_string())); + } + + comment_guard::enforce_comment_guard( + &ctx, + &CommentGuardInput { + ip_address: ip_address.as_deref(), + email: email.as_deref(), + user_agent: user_agent.as_deref(), + author: author.as_deref(), + content: content.as_deref(), + honeypot_website: params.website.as_deref(), + captcha_token: params.captcha_token.as_deref(), + captcha_answer: params.captcha_answer.as_deref(), + }, + ) + .await?; let mut item = ActiveModel { ..Default::default() @@ -144,47 +397,91 @@ pub async fn add( .as_deref() .and_then(|value| Uuid::parse_str(value).ok())); item.post_slug = Set(post_slug); - item.author = Set(params.author); - item.email = Set(params.email); - item.avatar = Set(params.avatar); - item.content = Set(params.content); + item.author = Set(author); + item.email = Set(email); + item.avatar = Set(avatar); + item.ip_address = Set(ip_address); + item.user_agent = Set(user_agent); + item.referer = Set(referer); + item.content = Set(content); + item.scope = Set(scope); + item.paragraph_key = Set(paragraph_key); + item.paragraph_excerpt = Set(paragraph_excerpt); item.reply_to = Set(params .reply_to .as_deref() .and_then(|value| Uuid::parse_str(value).ok())); + item.reply_to_comment_id = Set(params.reply_to_comment_id); item.approved = Set(Some(params.approved.unwrap_or(false))); let item = item.insert(&ctx.db).await?; + notifications::notify_new_comment(&ctx, &item).await; format::json(item) } #[debug_handler] pub async fn update( + headers: HeaderMap, Path(id): Path, State(ctx): State, Json(params): Json, ) -> Result { + let actor = check_auth(&headers)?; let item = load_item(&ctx, id).await?; let mut item = item.into_active_model(); params.update(&mut item); let item = item.update(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "comment.update", + "comment", + Some(item.id.to_string()), + item.post_slug.clone(), + Some(serde_json::json!({ "approved": item.approved })), + ) + .await?; format::json(item) } #[debug_handler] -pub async fn remove(Path(id): Path, State(ctx): State) -> Result { - load_item(&ctx, id).await?.delete(&ctx.db).await?; +pub async fn remove( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + let actor = check_auth(&headers)?; + let item = load_item(&ctx, id).await?; + let label = item.post_slug.clone(); + item.delete(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "comment.delete", + "comment", + Some(id.to_string()), + label, + None, + ) + .await?; format::empty() } #[debug_handler] -pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { +pub async fn get_one( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + check_auth(&headers)?; format::json(load_item(&ctx, id).await?) } pub fn routes() -> Routes { Routes::new() .prefix("api/comments/") + .add("captcha", get(captcha_challenge)) .add("/", get(list)) + .add("paragraphs/summary", get(paragraph_summary)) .add("/", post(add)) .add("{id}", get(get_one)) .add("{id}", delete(remove)) diff --git a/backend/src/controllers/content_analytics.rs b/backend/src/controllers/content_analytics.rs new file mode 100644 index 0000000..58b3a54 --- /dev/null +++ b/backend/src/controllers/content_analytics.rs @@ -0,0 +1,68 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] + +use axum::http::HeaderMap; +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::services::analytics; + +#[derive(Clone, Debug, Deserialize)] +pub struct ContentAnalyticsEventPayload { + pub event_type: String, + pub path: String, + #[serde(default)] + pub post_slug: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub progress_percent: Option, + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub referrer: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ContentAnalyticsEventResponse { + pub recorded: bool, +} + +#[debug_handler] +pub async fn record( + State(ctx): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + let mut request_context = analytics::content_request_context_from_headers(&payload.path, &headers); + if payload.referrer.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some() { + request_context.referrer = payload.referrer; + } + + analytics::record_content_event( + &ctx, + analytics::ContentEventDraft { + event_type: payload.event_type, + path: payload.path, + post_slug: payload.post_slug, + session_id: payload.session_id, + request_context, + duration_ms: payload.duration_ms, + progress_percent: payload.progress_percent, + metadata: payload.metadata, + }, + ) + .await; + + format::json(ContentAnalyticsEventResponse { recorded: true }) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/analytics/") + .add("content", post(record)) +} diff --git a/backend/src/controllers/friend_link.rs b/backend/src/controllers/friend_link.rs index 4edc11d..9816f60 100644 --- a/backend/src/controllers/friend_link.rs +++ b/backend/src/controllers/friend_link.rs @@ -1,11 +1,14 @@ #![allow(clippy::missing_errors_doc)] #![allow(clippy::unnecessary_struct_initialization)] #![allow(clippy::unused_async)] +use axum::http::HeaderMap; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, QueryFilter, QueryOrder}; use serde::{Deserialize, Serialize}; +use crate::controllers::admin::check_auth; use crate::models::_entities::friend_links::{ActiveModel, Column, Entity, Model}; +use crate::services::{admin_audit, notifications}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Params { @@ -69,11 +72,15 @@ async fn load_item(ctx: &AppContext, id: i32) -> Result { pub async fn list( Query(query): Query, State(ctx): State, + headers: HeaderMap, ) -> Result { + let authenticated = check_auth(&headers).ok(); let mut db_query = Entity::find().order_by_desc(Column::CreatedAt); if let Some(status) = query.status { db_query = db_query.filter(Column::Status.eq(status)); + } else if authenticated.is_none() { + db_query = db_query.filter(Column::Status.eq("approved")); } if let Some(category) = query.category { @@ -98,30 +105,65 @@ pub async fn add( item.category = Set(params.category); item.status = Set(Some(params.status.unwrap_or_else(|| "pending".to_string()))); let item = item.insert(&ctx.db).await?; + notifications::notify_new_friend_link(&ctx, &item).await; format::json(item) } #[debug_handler] pub async fn update( + headers: HeaderMap, Path(id): Path, State(ctx): State, Json(params): Json, ) -> Result { + let actor = check_auth(&headers)?; let item = load_item(&ctx, id).await?; let mut item = item.into_active_model(); params.update(&mut item); let item = item.update(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "friend_link.update", + "friend_link", + Some(item.id.to_string()), + item.site_name.clone().or_else(|| Some(item.site_url.clone())), + Some(serde_json::json!({ "status": item.status })), + ) + .await?; format::json(item) } #[debug_handler] -pub async fn remove(Path(id): Path, State(ctx): State) -> Result { - load_item(&ctx, id).await?.delete(&ctx.db).await?; +pub async fn remove( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + let actor = check_auth(&headers)?; + let item = load_item(&ctx, id).await?; + let label = item.site_name.clone().or_else(|| Some(item.site_url.clone())); + item.delete(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "friend_link.delete", + "friend_link", + Some(id.to_string()), + label, + None, + ) + .await?; format::empty() } #[debug_handler] -pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { +pub async fn get_one( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + check_auth(&headers)?; format::json(load_item(&ctx, id).await?) } diff --git a/backend/src/controllers/health.rs b/backend/src/controllers/health.rs new file mode 100644 index 0000000..bca65cd --- /dev/null +++ b/backend/src/controllers/health.rs @@ -0,0 +1,13 @@ +use loco_rs::prelude::*; + +#[debug_handler] +pub async fn healthz() -> Result { + format::json(serde_json::json!({ + "ok": true, + "service": "backend", + })) +} + +pub fn routes() -> Routes { + Routes::new().add("/healthz", get(healthz)) +} diff --git a/backend/src/controllers/mod.rs b/backend/src/controllers/mod.rs index bf678f9..a8a163f 100644 --- a/backend/src/controllers/mod.rs +++ b/backend/src/controllers/mod.rs @@ -1,10 +1,16 @@ pub mod admin; +pub mod admin_api; +pub mod admin_ops; +pub mod ai; pub mod auth; +pub mod content_analytics; pub mod category; pub mod comment; pub mod friend_link; +pub mod health; pub mod post; pub mod review; pub mod search; pub mod site_settings; +pub mod subscription; pub mod tag; diff --git a/backend/src/controllers/post.rs b/backend/src/controllers/post.rs index c4767ff..8b8b2ac 100644 --- a/backend/src/controllers/post.rs +++ b/backend/src/controllers/post.rs @@ -1,75 +1,66 @@ #![allow(clippy::missing_errors_doc)] #![allow(clippy::unnecessary_struct_initialization)] #![allow(clippy::unused_async)] + +use std::collections::HashSet; + +use axum::{extract::Multipart, http::HeaderMap}; +use chrono::{TimeZone, Utc}; use loco_rs::prelude::*; use sea_orm::QueryOrder; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::models::_entities::posts::{ActiveModel, Column, Entity, Model}; -use crate::services::content; +use crate::{ + controllers::admin::check_auth, + services::{admin_audit, content, post_revisions, subscriptions}, +}; -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Params { - pub title: Option, - pub slug: String, - pub description: Option, - pub content: Option, - pub category: Option, - pub tags: Option, - pub post_type: Option, - pub image: Option, - pub pinned: Option, +fn deserialize_boolish_option<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + + raw.map(|value| match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => Err(serde::de::Error::custom(format!( + "invalid boolean value `{other}`" + ))), + }) + .transpose() } -impl Params { - fn update(&self, item: &mut ActiveModel) { - item.title = Set(self.title.clone()); - item.slug = Set(self.slug.clone()); - item.description = Set(self.description.clone()); - item.content = Set(self.content.clone()); - item.category = Set(self.category.clone()); - item.tags = Set(self.tags.clone()); - item.post_type = Set(self.post_type.clone()); - item.image = Set(self.image.clone()); - item.pinned = Set(self.pinned); +fn normalize_slug_key(value: &str) -> String { + value.trim().trim_matches('/').to_string() +} + +fn request_preview_mode(preview: Option, headers: &HeaderMap) -> bool { + preview.unwrap_or(false) + || headers + .get("x-termi-post-mode") + .and_then(|value| value.to_str().ok()) + .map(|value| value.eq_ignore_ascii_case("preview")) + .unwrap_or(false) +} + +fn requested_status(status: Option, published: Option) -> String { + if let Some(status) = status.as_deref() { + return content::normalize_post_status(Some(status)); + } + + if published == Some(false) { + content::POST_STATUS_DRAFT.to_string() + } else { + content::POST_STATUS_PUBLISHED.to_string() } } -#[derive(Clone, Debug, Default, Deserialize)] -pub struct ListQuery { - pub slug: Option, - pub category: Option, - pub tag: Option, - pub search: Option, - #[serde(alias = "type")] - pub post_type: Option, - pub pinned: Option, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct MarkdownUpdateParams { - pub markdown: String, -} - -#[derive(Clone, Debug, Serialize)] -pub struct MarkdownDocumentResponse { - pub slug: String, - pub path: String, - pub markdown: String, -} - -async fn load_item(ctx: &AppContext, id: i32) -> Result { - let item = Entity::find_by_id(id).one(&ctx.db).await?; - item.ok_or_else(|| Error::NotFound) -} - -async fn load_item_by_slug(ctx: &AppContext, slug: &str) -> Result { - let item = Entity::find() - .filter(Column::Slug.eq(slug)) - .one(&ctx.db) - .await?; - - item.ok_or_else(|| Error::NotFound) +fn normalize_visibility(value: Option) -> String { + content::normalize_post_visibility(value.as_deref()) } fn post_has_tag(post: &Model, wanted_tag: &str) -> bool { @@ -87,158 +78,625 @@ fn post_has_tag(post: &Model, wanted_tag: &str) -> bool { .unwrap_or(false) } +fn effective_status(post: &Model) -> String { + content::effective_post_state( + post.status.as_deref().unwrap_or(content::POST_STATUS_PUBLISHED), + post.publish_at, + post.unpublish_at, + Utc::now().fixed_offset(), + ) +} + +fn listed_publicly(post: &Model) -> bool { + content::is_post_listed_publicly(post, Utc::now().fixed_offset()) +} + +fn publicly_accessible(post: &Model) -> bool { + content::is_post_publicly_accessible(post, Utc::now().fixed_offset()) +} + +fn parse_optional_markdown_datetime( + value: Option<&str>, +) -> Option> { + let value = value?.trim(); + if value.is_empty() { + return None; + } + + chrono::DateTime::parse_from_rfc3339(value).ok().or_else(|| { + chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") + .ok() + .and_then(|date| date.and_hms_opt(0, 0, 0)) + .and_then(|naive| { + chrono::FixedOffset::east_opt(0)? + .from_local_datetime(&naive) + .single() + }) + }) +} + +fn markdown_post_listed_publicly(post: &content::MarkdownPost) -> bool { + content::effective_post_state( + &post.status, + parse_optional_markdown_datetime(post.publish_at.as_deref()), + parse_optional_markdown_datetime(post.unpublish_at.as_deref()), + Utc::now().fixed_offset(), + ) == content::POST_STATUS_PUBLISHED + && post.visibility == content::POST_VISIBILITY_PUBLIC +} + +fn should_include_post( + post: &Model, + query: &ListQuery, + preview: bool, + include_private: bool, + include_redirects: bool, +) -> bool { + if !preview && !listed_publicly(post) { + return false; + } + + if query.listed_only.unwrap_or(!preview) && !listed_publicly(post) { + return false; + } + + if !include_private + && content::normalize_post_visibility(post.visibility.as_deref()) + == content::POST_VISIBILITY_PRIVATE + { + return false; + } + + if !include_redirects + && post + .redirect_to + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + return false; + } + + if let Some(slug) = &query.slug { + if post.slug != *slug { + return false; + } + } + + if let Some(category) = &query.category { + if post + .category + .as_deref() + .map(|value| !value.eq_ignore_ascii_case(category)) + .unwrap_or(true) + { + return false; + } + } + + if let Some(post_type) = &query.post_type { + if post + .post_type + .as_deref() + .map(|value| !value.eq_ignore_ascii_case(post_type)) + .unwrap_or(true) + { + return false; + } + } + + if let Some(pinned) = query.pinned { + if post.pinned.unwrap_or(false) != pinned { + return false; + } + } + + if let Some(tag) = &query.tag { + if !post_has_tag(post, tag) { + return false; + } + } + + if let Some(status) = &query.status { + if effective_status(post) != content::normalize_post_status(Some(status)) && effective_status(post) != status.trim().to_ascii_lowercase() { + return false; + } + } + + if let Some(visibility) = &query.visibility { + if content::normalize_post_visibility(post.visibility.as_deref()) + != content::normalize_post_visibility(Some(visibility)) + { + return false; + } + } + + if let Some(search) = &query.search { + let wanted = search.trim().to_lowercase(); + let haystack = [ + post.title.as_deref().unwrap_or_default(), + post.description.as_deref().unwrap_or_default(), + post.content.as_deref().unwrap_or_default(), + post.category.as_deref().unwrap_or_default(), + &post.slug, + ] + .join("\n") + .to_lowercase(); + + if !haystack.contains(&wanted) + && !post + .tags + .as_ref() + .and_then(|value| value.as_array()) + .map(|tags| { + tags.iter() + .filter_map(|tag| tag.as_str()) + .any(|tag| tag.to_lowercase().contains(&wanted)) + }) + .unwrap_or(false) + { + return false; + } + } + + true +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or(Error::NotFound) +} + +async fn load_item_by_slug_once(ctx: &AppContext, slug: &str) -> Result> { + Entity::find() + .filter(Column::Slug.eq(slug)) + .one(&ctx.db) + .await + .map_err(Into::into) +} + +async fn resolve_post_by_slug(ctx: &AppContext, slug: &str) -> Result { + let mut current_slug = normalize_slug_key(slug); + if current_slug.is_empty() { + return Err(Error::NotFound); + } + + let mut visited = HashSet::new(); + + loop { + if !visited.insert(current_slug.clone()) { + return Err(Error::NotFound); + } + + if let Some(post) = load_item_by_slug_once(ctx, ¤t_slug).await? { + let next_slug = post + .redirect_to + .as_deref() + .map(normalize_slug_key) + .filter(|value| !value.is_empty() && *value != post.slug); + + if let Some(next_slug) = next_slug { + current_slug = next_slug; + continue; + } + + return Ok(post); + } + + let candidates = Entity::find().all(&ctx.db).await?; + let Some(candidate) = candidates.into_iter().find(|item| { + content::post_redirects_from_json(&item.redirect_from) + .into_iter() + .any(|redirect| redirect.eq_ignore_ascii_case(¤t_slug)) + }) else { + return Err(Error::NotFound); + }; + + let next_slug = candidate + .redirect_to + .as_deref() + .map(normalize_slug_key) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| candidate.slug.clone()); + + if next_slug == candidate.slug { + return Ok(candidate); + } + + current_slug = next_slug; + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub title: Option, + pub slug: String, + pub description: Option, + pub content: Option, + pub category: Option, + pub tags: Option, + pub post_type: Option, + pub image: Option, + pub images: Option, + pub pinned: Option, + pub status: Option, + pub visibility: Option, + pub publish_at: Option, + pub unpublish_at: Option, + pub canonical_url: Option, + pub noindex: Option, + pub og_image: Option, + pub redirect_from: Option, + pub redirect_to: Option, +} + +impl Params { + fn update(&self, item: &mut ActiveModel) { + item.title = Set(self.title.clone()); + item.slug = Set(self.slug.clone()); + item.description = Set(self.description.clone()); + item.content = Set(self.content.clone()); + item.category = Set(self.category.clone()); + item.tags = Set(self.tags.clone()); + item.post_type = Set(self.post_type.clone()); + item.image = Set(self.image.clone()); + item.images = Set(self.images.clone()); + item.pinned = Set(self.pinned); + item.status = Set(self.status.clone().map(|value| requested_status(Some(value), None))); + item.visibility = Set( + self.visibility + .clone() + .map(|value| normalize_visibility(Some(value))), + ); + item.publish_at = Set( + self.publish_at + .clone() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()), + ); + item.unpublish_at = Set( + self.unpublish_at + .clone() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()), + ); + item.canonical_url = Set(self.canonical_url.clone()); + item.noindex = Set(self.noindex); + item.og_image = Set(self.og_image.clone()); + item.redirect_from = Set(self.redirect_from.clone()); + item.redirect_to = Set(self.redirect_to.clone()); + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ListQuery { + pub slug: Option, + pub category: Option, + pub tag: Option, + pub search: Option, + #[serde(alias = "type")] + pub post_type: Option, + pub pinned: Option, + pub status: Option, + pub visibility: Option, + #[serde(default, deserialize_with = "deserialize_boolish_option")] + pub listed_only: Option, + #[serde(default, deserialize_with = "deserialize_boolish_option")] + pub include_private: Option, + #[serde(default, deserialize_with = "deserialize_boolish_option")] + pub include_redirects: Option, + #[serde(default, deserialize_with = "deserialize_boolish_option")] + pub preview: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct LookupQuery { + #[serde(default, deserialize_with = "deserialize_boolish_option")] + pub preview: Option, + #[serde(default, deserialize_with = "deserialize_boolish_option")] + pub include_private: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct MarkdownUpdateParams { + pub markdown: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct MarkdownCreateParams { + pub title: String, + pub slug: Option, + pub description: Option, + pub content: Option, + pub category: Option, + pub tags: Option>, + pub post_type: Option, + pub image: Option, + pub images: Option>, + pub pinned: Option, + pub status: Option, + pub visibility: Option, + pub publish_at: Option, + pub unpublish_at: Option, + pub canonical_url: Option, + pub noindex: Option, + pub og_image: Option, + pub redirect_from: Option>, + pub redirect_to: Option, + pub published: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct MarkdownDocumentResponse { + pub slug: String, + pub path: String, + pub markdown: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct MarkdownDeleteResponse { + pub slug: String, + pub deleted: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct MarkdownImportResponse { + pub count: usize, + pub slugs: Vec, +} + #[debug_handler] pub async fn list( Query(query): Query, State(ctx): State, + headers: HeaderMap, ) -> Result { content::sync_markdown_posts(&ctx).await?; + let preview = request_preview_mode(query.preview, &headers); + let include_private = preview && query.include_private.unwrap_or(true); + let include_redirects = query.include_redirects.unwrap_or(preview); + let posts = Entity::find() .order_by_desc(Column::CreatedAt) .all(&ctx.db) .await?; - let filtered: Vec = posts + let filtered = posts .into_iter() - .filter(|post| { - if let Some(slug) = &query.slug { - if post.slug != *slug { - return false; - } - } - - if let Some(category) = &query.category { - if post - .category - .as_deref() - .map(|value| !value.eq_ignore_ascii_case(category)) - .unwrap_or(true) - { - return false; - } - } - - if let Some(post_type) = &query.post_type { - if post - .post_type - .as_deref() - .map(|value| !value.eq_ignore_ascii_case(post_type)) - .unwrap_or(true) - { - return false; - } - } - - if let Some(pinned) = query.pinned { - if post.pinned.unwrap_or(false) != pinned { - return false; - } - } - - if let Some(tag) = &query.tag { - if !post_has_tag(post, tag) { - return false; - } - } - - if let Some(search) = &query.search { - let wanted = search.trim().to_lowercase(); - let haystack = [ - post.title.as_deref().unwrap_or_default(), - post.description.as_deref().unwrap_or_default(), - post.content.as_deref().unwrap_or_default(), - post.category.as_deref().unwrap_or_default(), - &post.slug, - ] - .join("\n") - .to_lowercase(); - - if !haystack.contains(&wanted) - && !post - .tags - .as_ref() - .and_then(|value| value.as_array()) - .map(|tags| { - tags.iter() - .filter_map(|tag| tag.as_str()) - .any(|tag| tag.to_lowercase().contains(&wanted)) - }) - .unwrap_or(false) - { - return false; - } - } - - true - }) - .collect(); + .filter(|post| should_include_post(post, &query, preview, include_private, include_redirects)) + .collect::>(); format::json(filtered) } #[debug_handler] -pub async fn add(State(ctx): State, Json(params): Json) -> Result { +pub async fn add( + headers: HeaderMap, + State(ctx): State, + Json(params): Json, +) -> Result { + let actor = check_auth(&headers)?; let mut item = ActiveModel { ..Default::default() }; params.update(&mut item); let item = item.insert(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "post.create", + "post", + Some(item.id.to_string()), + Some(item.slug.clone()), + None, + ) + .await?; format::json(item) } #[debug_handler] pub async fn update( + headers: HeaderMap, Path(id): Path, State(ctx): State, Json(params): Json, ) -> Result { - let item = load_item(&ctx, id).await?; - let mut item = item.into_active_model(); + let actor = check_auth(&headers)?; + let previous = load_item(&ctx, id).await?; + let was_public = content::is_post_listed_publicly(&previous, Utc::now().fixed_offset()); + let previous_slug = previous.slug.clone(); + + let mut item = previous.into_active_model(); params.update(&mut item); let item = item.update(&ctx.db).await?; + let is_public = content::is_post_listed_publicly(&item, Utc::now().fixed_offset()); + + admin_audit::log_event( + &ctx, + Some(&actor), + "post.update", + "post", + Some(item.id.to_string()), + Some(item.slug.clone()), + Some(serde_json::json!({ + "previous_slug": previous_slug, + "published": is_public, + })), + ) + .await?; + + if is_public && !was_public { + let post = content::MarkdownPost { + title: item.title.clone().unwrap_or_else(|| item.slug.clone()), + slug: item.slug.clone(), + description: item.description.clone(), + content: item.content.clone().unwrap_or_default(), + category: item.category.clone(), + tags: item + .tags + .as_ref() + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|tag| tag.as_str().map(ToString::to_string)) + .collect(), + post_type: item.post_type.clone().unwrap_or_else(|| "article".to_string()), + image: item.image.clone(), + images: item + .images + .as_ref() + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|tag| tag.as_str().map(ToString::to_string)) + .collect(), + pinned: item.pinned.unwrap_or(false), + status: item.status.clone().unwrap_or_else(|| content::POST_STATUS_PUBLISHED.to_string()), + visibility: item + .visibility + .clone() + .unwrap_or_else(|| content::POST_VISIBILITY_PUBLIC.to_string()), + publish_at: item.publish_at.map(|value| value.to_rfc3339()), + unpublish_at: item.unpublish_at.map(|value| value.to_rfc3339()), + canonical_url: item.canonical_url.clone(), + noindex: item.noindex.unwrap_or(false), + og_image: item.og_image.clone(), + redirect_from: content::post_redirects_from_json(&item.redirect_from), + redirect_to: item.redirect_to.clone(), + file_path: content::markdown_post_path(&item.slug) + .to_string_lossy() + .to_string(), + }; + let _ = subscriptions::notify_post_published(&ctx, &post).await; + } + format::json(item) } #[debug_handler] -pub async fn remove(Path(id): Path, State(ctx): State) -> Result { - load_item(&ctx, id).await?.delete(&ctx.db).await?; +pub async fn remove( + headers: HeaderMap, + Path(id): Path, + State(ctx): State, +) -> Result { + let actor = check_auth(&headers)?; + let item = load_item(&ctx, id).await?; + let slug = item.slug.clone(); + item.delete(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "post.delete", + "post", + Some(id.to_string()), + Some(slug), + None, + ) + .await?; format::empty() } #[debug_handler] -pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { +pub async fn get_one( + Path(id): Path, + Query(query): Query, + State(ctx): State, + headers: HeaderMap, +) -> Result { content::sync_markdown_posts(&ctx).await?; - format::json(load_item(&ctx, id).await?) + let preview = request_preview_mode(query.preview, &headers); + let post = load_item(&ctx, id).await?; + + if !preview && !publicly_accessible(&post) { + return Err(Error::NotFound); + } + + format::json(post) } #[debug_handler] pub async fn get_by_slug( Path(slug): Path, + Query(query): Query, State(ctx): State, + headers: HeaderMap, ) -> Result { content::sync_markdown_posts(&ctx).await?; - format::json(load_item_by_slug(&ctx, &slug).await?) + let preview = request_preview_mode(query.preview, &headers); + let include_private = preview && query.include_private.unwrap_or(true); + let post = resolve_post_by_slug(&ctx, &slug).await?; + + if !preview && !publicly_accessible(&post) { + return Err(Error::NotFound); + } + + if !include_private + && content::normalize_post_visibility(post.visibility.as_deref()) + == content::POST_VISIBILITY_PRIVATE + { + return Err(Error::NotFound); + } + + format::json(post) } #[debug_handler] pub async fn get_markdown_by_slug( + headers: HeaderMap, Path(slug): Path, State(ctx): State, ) -> Result { + check_auth(&headers)?; content::sync_markdown_posts(&ctx).await?; let (path, markdown) = content::read_markdown_document(&slug)?; - format::json(MarkdownDocumentResponse { slug, path, markdown }) + format::json(MarkdownDocumentResponse { + slug, + path, + markdown, + }) } #[debug_handler] pub async fn update_markdown_by_slug( + headers: HeaderMap, Path(slug): Path, State(ctx): State, Json(params): Json, ) -> Result { + let actor = check_auth(&headers)?; + let _ = post_revisions::capture_current_snapshot( + &ctx, + Some(&actor), + &slug, + "update", + Some("保存文章前的自动快照"), + None, + ) + .await?; let updated = content::write_markdown_document(&ctx, &slug, ¶ms.markdown).await?; let (path, markdown) = content::read_markdown_document(&updated.slug)?; + let _ = post_revisions::capture_snapshot_from_markdown( + &ctx, + Some(&actor), + &updated.slug, + &markdown, + "saved", + Some("保存后的当前版本"), + None, + ) + .await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "post.markdown.update", + "post", + None, + Some(updated.slug.clone()), + None, + ) + .await?; format::json(MarkdownDocumentResponse { slug: updated.slug, @@ -247,14 +705,185 @@ pub async fn update_markdown_by_slug( }) } +#[debug_handler] +pub async fn create_markdown( + headers: HeaderMap, + State(ctx): State, + Json(params): Json, +) -> Result { + let actor = check_auth(&headers)?; + let title = params.title.trim(); + if title.is_empty() { + return Err(Error::BadRequest("title is required".to_string())); + } + + let default_body = format!("# {title}\n"); + let created = content::create_markdown_post( + &ctx, + content::MarkdownPostDraft { + title: title.to_string(), + slug: params.slug, + description: params.description, + content: params.content.unwrap_or(default_body), + category: params.category, + tags: params.tags.unwrap_or_default(), + post_type: params.post_type.unwrap_or_else(|| "article".to_string()), + image: params.image, + images: params.images.unwrap_or_default(), + pinned: params.pinned.unwrap_or(false), + status: requested_status(params.status, params.published), + visibility: normalize_visibility(params.visibility), + publish_at: params.publish_at, + unpublish_at: params.unpublish_at, + canonical_url: params.canonical_url, + noindex: params.noindex.unwrap_or(false), + og_image: params.og_image, + redirect_from: params.redirect_from.unwrap_or_default(), + redirect_to: params.redirect_to, + }, + ) + .await?; + let (path, markdown) = content::read_markdown_document(&created.slug)?; + let _ = post_revisions::capture_snapshot_from_markdown( + &ctx, + Some(&actor), + &created.slug, + &markdown, + "create", + Some("新建文章"), + None, + ) + .await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "post.markdown.create", + "post", + None, + Some(created.slug.clone()), + None, + ) + .await?; + if markdown_post_listed_publicly(&created) { + let _ = subscriptions::notify_post_published(&ctx, &created).await; + } + + format::json(MarkdownDocumentResponse { + slug: created.slug, + path, + markdown, + }) +} + +#[debug_handler] +pub async fn import_markdown( + headers: HeaderMap, + State(ctx): State, + mut multipart: Multipart, +) -> Result { + let actor = check_auth(&headers)?; + let mut files = Vec::new(); + + while let Some(field) = multipart + .next_field() + .await + .map_err(|error| Error::BadRequest(error.to_string()))? + { + let file_name = field + .file_name() + .map(ToString::to_string) + .unwrap_or_else(|| "imported.md".to_string()); + let bytes = field + .bytes() + .await + .map_err(|error| Error::BadRequest(error.to_string()))?; + let content = String::from_utf8(bytes.to_vec()) + .map_err(|_| Error::BadRequest("markdown file must be utf-8".to_string()))?; + + files.push(content::MarkdownImportFile { file_name, content }); + } + + let imported = content::import_markdown_documents(&ctx, files).await?; + for item in &imported { + if let Ok((_path, markdown)) = content::read_markdown_document(&item.slug) { + let _ = post_revisions::capture_snapshot_from_markdown( + &ctx, + Some(&actor), + &item.slug, + &markdown, + "import", + Some("批量导入 Markdown"), + None, + ) + .await; + } + if markdown_post_listed_publicly(item) { + let _ = subscriptions::notify_post_published(&ctx, item).await; + } + } + admin_audit::log_event( + &ctx, + Some(&actor), + "post.markdown.import", + "post_import", + None, + Some(format!("{} files", imported.len())), + Some(serde_json::json!({ + "slugs": imported.iter().map(|item| item.slug.clone()).collect::>(), + })), + ) + .await?; + + format::json(MarkdownImportResponse { + count: imported.len(), + slugs: imported.into_iter().map(|item| item.slug).collect(), + }) +} + +#[debug_handler] +pub async fn delete_markdown_by_slug( + headers: HeaderMap, + Path(slug): Path, + State(ctx): State, +) -> Result { + let actor = check_auth(&headers)?; + let _ = post_revisions::capture_current_snapshot( + &ctx, + Some(&actor), + &slug, + "delete", + Some("删除前自动快照"), + None, + ) + .await?; + content::delete_markdown_post(&ctx, &slug).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "post.markdown.delete", + "post", + None, + Some(slug.clone()), + None, + ) + .await?; + format::json(MarkdownDeleteResponse { + slug, + deleted: true, + }) +} + pub fn routes() -> Routes { Routes::new() .prefix("api/posts/") .add("/", get(list)) .add("/", post(add)) + .add("markdown", post(create_markdown)) + .add("markdown/import", post(import_markdown)) .add("slug/{slug}/markdown", get(get_markdown_by_slug)) .add("slug/{slug}/markdown", put(update_markdown_by_slug)) .add("slug/{slug}/markdown", patch(update_markdown_by_slug)) + .add("slug/{slug}/markdown", delete(delete_markdown_by_slug)) .add("slug/{slug}", get(get_by_slug)) .add("{id}", get(get_one)) .add("{id}", delete(remove)) diff --git a/backend/src/controllers/review.rs b/backend/src/controllers/review.rs index 6757444..a95d491 100644 --- a/backend/src/controllers/review.rs +++ b/backend/src/controllers/review.rs @@ -1,9 +1,16 @@ -use axum::extract::{Path, State}; +use axum::{ + extract::{Path, State}, + http::HeaderMap, +}; use loco_rs::prelude::*; use sea_orm::{EntityTrait, QueryOrder, Set}; use serde::{Deserialize, Serialize}; -use crate::models::_entities::reviews::{self, Entity as ReviewEntity}; +use crate::{ + controllers::admin::check_auth, + models::_entities::reviews::{self, Entity as ReviewEntity}, + services::{admin_audit, storage}, +}; #[derive(Serialize, Deserialize, Debug)] pub struct CreateReviewRequest { @@ -15,6 +22,7 @@ pub struct CreateReviewRequest { pub description: String, pub tags: Vec, pub cover: String, + pub link_url: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -27,6 +35,7 @@ pub struct UpdateReviewRequest { pub description: Option, pub tags: Option>, pub cover: Option, + pub link_url: Option, } pub async fn list(State(ctx): State) -> Result { @@ -51,9 +60,11 @@ pub async fn get_one( } pub async fn create( + headers: HeaderMap, State(ctx): State, Json(req): Json, ) -> Result { + let actor = check_auth(&headers)?; let new_review = reviews::ActiveModel { title: Set(Some(req.title)), review_type: Set(Some(req.review_type)), @@ -63,23 +74,41 @@ pub async fn create( description: Set(Some(req.description)), tags: Set(Some(serde_json::to_string(&req.tags).unwrap_or_default())), cover: Set(Some(req.cover)), + link_url: Set(req.link_url.and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + })), ..Default::default() }; let review = new_review.insert(&ctx.db).await?; + admin_audit::log_event( + &ctx, + Some(&actor), + "review.create", + "review", + Some(review.id.to_string()), + review.title.clone(), + None, + ) + .await?; format::json(review) } pub async fn update( + headers: HeaderMap, Path(id): Path, State(ctx): State, Json(req): Json, ) -> Result { + let actor = check_auth(&headers)?; let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; - let Some(mut review) = review.map(|r| r.into_active_model()) else { + let Some(existing_review) = review else { return Err(Error::NotFound); }; + let old_cover = existing_review.cover.clone(); + let mut review = existing_review.into_active_model(); if let Some(title) = req.title { review.title = Set(Some(title)); @@ -102,23 +131,66 @@ pub async fn update( if let Some(tags) = req.tags { review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default())); } + let mut next_cover = old_cover.clone(); if let Some(cover) = req.cover { + next_cover = Some(cover.clone()); review.cover = Set(Some(cover)); } + if let Some(link_url) = req.link_url { + let trimmed = link_url.trim().to_string(); + review.link_url = Set((!trimmed.is_empty()).then_some(trimmed)); + } let review = review.update(&ctx.db).await?; + if let Some(old_cover) = old_cover + .filter(|old| Some(old.clone()) != next_cover) + .filter(|old| !old.trim().is_empty()) + { + if let Err(error) = storage::delete_managed_url(&ctx, &old_cover).await { + tracing::warn!("failed to cleanup replaced review cover: {error}"); + } + } + admin_audit::log_event( + &ctx, + Some(&actor), + "review.update", + "review", + Some(review.id.to_string()), + review.title.clone(), + None, + ) + .await?; format::json(review) } pub async fn remove( + headers: HeaderMap, Path(id): Path, State(ctx): State, ) -> Result { + let actor = check_auth(&headers)?; let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; match review { Some(r) => { + let cover = r.cover.clone(); + let title = r.title.clone(); r.delete(&ctx.db).await?; + if let Some(cover) = cover.filter(|value| !value.trim().is_empty()) { + if let Err(error) = storage::delete_managed_url(&ctx, &cover).await { + tracing::warn!("failed to cleanup deleted review cover: {error}"); + } + } + admin_audit::log_event( + &ctx, + Some(&actor), + "review.delete", + "review", + Some(id.to_string()), + title, + None, + ) + .await?; format::empty() } None => Err(Error::NotFound), diff --git a/backend/src/controllers/search.rs b/backend/src/controllers/search.rs index 4322bf1..5caf937 100644 --- a/backend/src/controllers/search.rs +++ b/backend/src/controllers/search.rs @@ -1,18 +1,292 @@ +use axum::http::HeaderMap; use loco_rs::prelude::*; -use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; +use std::{collections::HashSet, time::Instant}; -use crate::models::_entities::posts; -use crate::services::content; +use crate::{ + controllers::site_settings, + models::_entities::posts, + services::{abuse_guard, analytics, content}, +}; + +fn deserialize_boolish_option<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + + raw.map(|value| match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => Err(serde::de::Error::custom(format!( + "invalid boolean value `{other}`" + ))), + }) + .transpose() +} + +fn normalize_text(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_ascii_lowercase() +} + +fn tokenize(value: &str) -> Vec { + value + .split(|ch: char| !ch.is_alphanumeric() && ch != '-' && ch != '_') + .map(normalize_text) + .filter(|item| !item.is_empty()) + .collect() +} + +fn levenshtein_distance(left: &str, right: &str) -> usize { + if left == right { + return 0; + } + if left.is_empty() { + return right.chars().count(); + } + if right.is_empty() { + return left.chars().count(); + } + + let right_chars = right.chars().collect::>(); + let mut prev = (0..=right_chars.len()).collect::>(); + + for (i, left_ch) in left.chars().enumerate() { + let mut curr = vec![i + 1; right_chars.len() + 1]; + for (j, right_ch) in right_chars.iter().enumerate() { + let cost = usize::from(left_ch != *right_ch); + curr[j + 1] = (curr[j] + 1) + .min(prev[j + 1] + 1) + .min(prev[j] + cost); + } + prev = curr; + } + + prev[right_chars.len()] +} + +fn parse_synonym_groups(value: &Option) -> Vec> { + value + .as_ref() + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| item.as_str().map(ToString::to_string)) + .map(|item| { + let normalized = item.replace("=>", ",").replace('|', ","); + normalized + .split([',', ',']) + .map(normalize_text) + .filter(|token| !token.is_empty()) + .collect::>() + }) + .filter(|group| !group.is_empty()) + .collect() +} + +fn expand_search_terms(query: &str, synonym_groups: &[Vec]) -> Vec { + let normalized_query = normalize_text(query); + let query_tokens = tokenize(query); + let mut expanded = Vec::new(); + let mut seen = HashSet::new(); + + if !normalized_query.is_empty() && seen.insert(normalized_query.clone()) { + expanded.push(normalized_query.clone()); + } + + for token in &query_tokens { + if seen.insert(token.clone()) { + expanded.push(token.clone()); + } + } + + for group in synonym_groups { + let matched = group.iter().any(|item| { + *item == normalized_query + || query_tokens.iter().any(|token| token == item) + || normalized_query.contains(item) + }); + + if matched { + for token in group { + if seen.insert(token.clone()) { + expanded.push(token.clone()); + } + } + } + } + + expanded +} + +fn candidate_terms(posts: &[posts::Model]) -> Vec { + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + + for post in posts { + for source in [ + post.title.as_deref().unwrap_or_default(), + post.category.as_deref().unwrap_or_default(), + &post.slug, + ] { + for token in tokenize(source) { + if token.len() >= 3 && seen.insert(token.clone()) { + candidates.push(token); + } + } + } + + if let Some(tags) = post.tags.as_ref().and_then(Value::as_array) { + for token in tags.iter().filter_map(Value::as_str).flat_map(tokenize) { + if token.len() >= 2 && seen.insert(token.clone()) { + candidates.push(token); + } + } + } + } + + candidates +} + +fn find_spelling_fallback(query: &str, posts: &[posts::Model], synonym_groups: &[Vec]) -> Vec { + let primary_token = tokenize(query).into_iter().next().unwrap_or_default(); + if primary_token.len() < 3 { + return Vec::new(); + } + + let mut nearest = candidate_terms(posts) + .into_iter() + .map(|candidate| { + let distance = levenshtein_distance(&primary_token, &candidate); + (candidate, distance) + }) + .filter(|(_, distance)| *distance <= 2) + .collect::>(); + nearest.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0))); + + nearest + .into_iter() + .take(3) + .flat_map(|(candidate, _)| expand_search_terms(&candidate, synonym_groups)) + .collect() +} + +fn post_has_tag(post: &posts::Model, wanted_tag: &str) -> bool { + let wanted = normalize_text(wanted_tag); + + post.tags + .as_ref() + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(Value::as_str) + .map(normalize_text) + .any(|tag| tag == wanted) + }) + .unwrap_or(false) +} + +fn score_post(post: &posts::Model, query: &str, terms: &[String]) -> f64 { + let normalized_query = normalize_text(query); + let title = normalize_text(post.title.as_deref().unwrap_or_default()); + let description = normalize_text(post.description.as_deref().unwrap_or_default()); + let content_text = normalize_text(post.content.as_deref().unwrap_or_default()); + let category = normalize_text(post.category.as_deref().unwrap_or_default()); + let slug = normalize_text(&post.slug); + let tags = post + .tags + .as_ref() + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| item.as_str().map(normalize_text)) + .collect::>(); + + let mut score = 0.0; + + if !normalized_query.is_empty() { + if title.contains(&normalized_query) { + score += 6.0; + } + if description.contains(&normalized_query) { + score += 4.0; + } + if slug.contains(&normalized_query) { + score += 4.0; + } + if category.contains(&normalized_query) { + score += 3.0; + } + if tags.iter().any(|tag| tag.contains(&normalized_query)) { + score += 4.0; + } + if content_text.contains(&normalized_query) { + score += 2.0; + } + } + + for term in terms { + if term.is_empty() { + continue; + } + + if title.contains(term) { + score += 3.5; + } + if description.contains(term) { + score += 2.2; + } + if slug.contains(term) { + score += 2.0; + } + if category.contains(term) { + score += 1.8; + } + if tags.iter().any(|tag| tag == term) { + score += 2.5; + } else if tags.iter().any(|tag| tag.contains(term)) { + score += 1.5; + } + if content_text.contains(term) { + score += 0.8; + } + } + + score +} + +fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool { + query.preview.unwrap_or(false) + || headers + .get("x-termi-search-mode") + .and_then(|value| value.to_str().ok()) + .map(|value| value.eq_ignore_ascii_case("preview")) + .unwrap_or(false) +} #[derive(Clone, Debug, Default, Deserialize)] pub struct SearchQuery { pub q: Option, pub limit: Option, + pub category: Option, + pub tag: Option, + #[serde(alias = "type")] + pub post_type: Option, + #[serde(default, deserialize_with = "deserialize_boolish_option")] + pub preview: Option, } -#[derive(Clone, Debug, Serialize, FromQueryResult)] +#[derive(Clone, Debug, Serialize)] pub struct SearchResult { pub id: i32, pub title: Option, @@ -28,136 +302,14 @@ pub struct SearchResult { pub rank: f64, } -fn search_sql() -> &'static str { - r#" - SELECT - p.id, - p.title, - p.slug, - p.description, - p.content, - p.category, - p.tags, - p.post_type, - p.pinned, - p.created_at, - p.updated_at, - ts_rank_cd( - setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') || - setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') || - setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') || - setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(p.content, '')), 'D'), - plainto_tsquery('simple', $1) - )::float8 AS rank - FROM posts p - WHERE ( - setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') || - setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') || - setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') || - setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(p.content, '')), 'D') - ) @@ plainto_tsquery('simple', $1) - ORDER BY rank DESC, p.created_at DESC - LIMIT $2 - "# -} - -fn app_level_rank(post: &posts::Model, wanted: &str) -> f64 { - let wanted_lower = wanted.to_lowercase(); - let mut rank = 0.0; - - if post - .title - .as_deref() - .unwrap_or_default() - .to_lowercase() - .contains(&wanted_lower) - { - rank += 4.0; - } - - if post - .description - .as_deref() - .unwrap_or_default() - .to_lowercase() - .contains(&wanted_lower) - { - rank += 2.5; - } - - if post - .content - .as_deref() - .unwrap_or_default() - .to_lowercase() - .contains(&wanted_lower) - { - rank += 1.0; - } - - if post - .category - .as_deref() - .unwrap_or_default() - .to_lowercase() - .contains(&wanted_lower) - { - rank += 1.5; - } - - if post - .tags - .as_ref() - .and_then(Value::as_array) - .map(|tags| { - tags.iter() - .filter_map(Value::as_str) - .any(|tag| tag.to_lowercase().contains(&wanted_lower)) - }) - .unwrap_or(false) - { - rank += 2.0; - } - - rank -} - -async fn fallback_search(ctx: &AppContext, q: &str, limit: u64) -> Result> { - let mut results = posts::Entity::find().all(&ctx.db).await?; - results.sort_by(|left, right| right.created_at.cmp(&left.created_at)); - - Ok(results - .into_iter() - .map(|post| { - let rank = app_level_rank(&post, q); - (post, rank) - }) - .filter(|(_, rank)| *rank > 0.0) - .take(limit as usize) - .map(|(post, rank)| SearchResult { - id: post.id, - title: post.title, - slug: post.slug, - description: post.description, - content: post.content, - category: post.category, - tags: post.tags, - post_type: post.post_type, - pinned: post.pinned, - created_at: post.created_at.into(), - updated_at: post.updated_at.into(), - rank, - }) - .collect()) -} - #[debug_handler] pub async fn search( Query(query): Query, State(ctx): State, + headers: HeaderMap, ) -> Result { + let started_at = Instant::now(); + let preview_search = is_preview_search(&query, &headers); content::sync_markdown_posts(&ctx).await?; let q = query.q.unwrap_or_default().trim().to_string(); @@ -165,22 +317,118 @@ pub async fn search( return format::json(Vec::::new()); } - let limit = query.limit.unwrap_or(20).clamp(1, 100); + if !preview_search { + abuse_guard::enforce_public_scope( + "search", + abuse_guard::detect_client_ip(&headers).as_deref(), + Some(&q), + )?; + } - let results = if ctx.db.get_database_backend() == DatabaseBackend::Postgres { - let statement = Statement::from_sql_and_values( - DbBackend::Postgres, - search_sql(), - [q.clone().into(), (limit as i64).into()], - ); + let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize; + let settings = site_settings::load_current(&ctx).await.ok(); + let synonym_groups = settings + .as_ref() + .map(|item| parse_synonym_groups(&item.search_synonyms)) + .unwrap_or_default(); - match SearchResult::find_by_statement(statement).all(&ctx.db).await { - Ok(rows) => rows, - Err(_) => fallback_search(&ctx, &q, limit).await?, + let mut all_posts = posts::Entity::find() + .all(&ctx.db) + .await? + .into_iter() + .filter(|post| { + preview_search + || content::is_post_listed_publicly(post, chrono::Utc::now().fixed_offset()) + }) + .collect::>(); + + if let Some(category) = query.category.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + all_posts.retain(|post| { + post.category + .as_deref() + .map(|value| value.eq_ignore_ascii_case(category)) + .unwrap_or(false) + }); + } + + if let Some(tag) = query.tag.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + all_posts.retain(|post| post_has_tag(post, tag)); + } + + if let Some(post_type) = query.post_type.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + all_posts.retain(|post| { + post.post_type + .as_deref() + .map(|value| value.eq_ignore_ascii_case(post_type)) + .unwrap_or(false) + }); + } + + let mut expanded_terms = expand_search_terms(&q, &synonym_groups); + let mut results = all_posts + .iter() + .map(|post| (post, score_post(post, &q, &expanded_terms))) + .filter(|(_, rank)| *rank > 0.0) + .map(|(post, rank)| SearchResult { + id: post.id, + title: post.title.clone(), + slug: post.slug.clone(), + description: post.description.clone(), + content: post.content.clone(), + category: post.category.clone(), + tags: post.tags.clone(), + post_type: post.post_type.clone(), + pinned: post.pinned, + created_at: post.created_at.into(), + updated_at: post.updated_at.into(), + rank, + }) + .collect::>(); + + if results.is_empty() { + expanded_terms = find_spelling_fallback(&q, &all_posts, &synonym_groups); + if !expanded_terms.is_empty() { + results = all_posts + .iter() + .map(|post| (post, score_post(post, &q, &expanded_terms))) + .filter(|(_, rank)| *rank > 0.0) + .map(|(post, rank)| SearchResult { + id: post.id, + title: post.title.clone(), + slug: post.slug.clone(), + description: post.description.clone(), + content: post.content.clone(), + category: post.category.clone(), + tags: post.tags.clone(), + post_type: post.post_type.clone(), + pinned: post.pinned, + created_at: post.created_at.into(), + updated_at: post.updated_at.into(), + rank, + }) + .collect::>(); } - } else { - fallback_search(&ctx, &q, limit).await? - }; + } + + results.sort_by(|left, right| { + right + .rank + .partial_cmp(&left.rank) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| right.created_at.cmp(&left.created_at)) + }); + results.truncate(limit); + + if !preview_search { + analytics::record_search_event( + &ctx, + &q, + results.len(), + &headers, + started_at.elapsed().as_millis() as i64, + ) + .await; + } format::json(results) } diff --git a/backend/src/controllers/site_settings.rs b/backend/src/controllers/site_settings.rs index cb850af..8d26345 100644 --- a/backend/src/controllers/site_settings.rs +++ b/backend/src/controllers/site_settings.rs @@ -2,11 +2,56 @@ #![allow(clippy::unnecessary_struct_initialization)] #![allow(clippy::unused_async)] +use axum::http::HeaderMap; use loco_rs::prelude::*; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use uuid::Uuid; -use crate::models::_entities::site_settings::{self, ActiveModel, Entity, Model}; +use crate::{ + controllers::admin::check_auth, + models::_entities::{ + categories, friend_links, posts, + site_settings::{self, ActiveModel, Entity, Model}, + tags, + }, + services::{ai, content}, +}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct MusicTrackPayload { + pub title: String, + #[serde(default)] + pub artist: Option, + #[serde(default)] + pub album: Option, + pub url: String, + #[serde(default, alias = "coverImageUrl")] + pub cover_image_url: Option, + #[serde(default, alias = "accentColor")] + pub accent_color: Option, + #[serde(default)] + pub description: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct AiProviderConfig { + #[serde(default)] + pub id: String, + #[serde(default, alias = "label")] + pub name: String, + #[serde(default)] + pub provider: String, + #[serde(default, alias = "apiBase")] + pub api_base: Option, + #[serde(default, alias = "apiKey")] + pub api_key: Option, + #[serde(default, alias = "chatModel")] + pub chat_model: Option, + #[serde(default, alias = "imageModel")] + pub image_model: Option, +} #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct SiteSettingsPayload { @@ -42,6 +87,110 @@ pub struct SiteSettingsPayload { pub location: Option, #[serde(default, alias = "techStack")] pub tech_stack: Option>, + #[serde(default, alias = "musicPlaylist")] + pub music_playlist: Option>, + #[serde(default, alias = "aiEnabled")] + pub ai_enabled: Option, + #[serde(default, alias = "paragraphCommentsEnabled")] + pub paragraph_comments_enabled: Option, + #[serde(default, alias = "aiProvider")] + pub ai_provider: Option, + #[serde(default, alias = "aiApiBase")] + pub ai_api_base: Option, + #[serde(default, alias = "aiApiKey")] + pub ai_api_key: Option, + #[serde(default, alias = "aiChatModel")] + pub ai_chat_model: Option, + #[serde(default, alias = "aiImageProvider")] + pub ai_image_provider: Option, + #[serde(default, alias = "aiImageApiBase")] + pub ai_image_api_base: Option, + #[serde(default, alias = "aiImageApiKey")] + pub ai_image_api_key: Option, + #[serde(default, alias = "aiImageModel")] + pub ai_image_model: Option, + #[serde(default, alias = "aiProviders")] + pub ai_providers: Option>, + #[serde(default, alias = "aiActiveProviderId")] + pub ai_active_provider_id: Option, + #[serde(default, alias = "aiEmbeddingModel")] + pub ai_embedding_model: Option, + #[serde(default, alias = "aiSystemPrompt")] + pub ai_system_prompt: Option, + #[serde(default, alias = "aiTopK")] + pub ai_top_k: Option, + #[serde(default, alias = "aiChunkSize")] + pub ai_chunk_size: Option, + #[serde(default, alias = "mediaR2AccountId")] + pub media_r2_account_id: Option, + #[serde(default, alias = "mediaStorageProvider")] + pub media_storage_provider: Option, + #[serde(default, alias = "mediaR2Bucket")] + pub media_r2_bucket: Option, + #[serde(default, alias = "mediaR2PublicBaseUrl")] + pub media_r2_public_base_url: Option, + #[serde(default, alias = "mediaR2AccessKeyId")] + pub media_r2_access_key_id: Option, + #[serde(default, alias = "mediaR2SecretAccessKey")] + pub media_r2_secret_access_key: Option, + #[serde(default, alias = "seoDefaultOgImage")] + pub seo_default_og_image: Option, + #[serde(default, alias = "seoDefaultTwitterHandle")] + pub seo_default_twitter_handle: Option, + #[serde(default, alias = "notificationWebhookUrl")] + pub notification_webhook_url: Option, + #[serde(default, alias = "notificationCommentEnabled")] + pub notification_comment_enabled: Option, + #[serde(default, alias = "notificationFriendLinkEnabled")] + pub notification_friend_link_enabled: Option, + #[serde(default, alias = "searchSynonyms")] + pub search_synonyms: Option>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicSiteSettingsResponse { + pub id: i32, + pub site_name: Option, + pub site_short_name: Option, + pub site_url: Option, + pub site_title: Option, + pub site_description: Option, + pub hero_title: Option, + pub hero_subtitle: Option, + pub owner_name: Option, + pub owner_title: Option, + pub owner_bio: Option, + pub owner_avatar_url: Option, + pub social_github: Option, + pub social_twitter: Option, + pub social_email: Option, + pub location: Option, + pub tech_stack: Option, + pub music_playlist: Option, + pub ai_enabled: bool, + pub paragraph_comments_enabled: bool, + pub seo_default_og_image: Option, + pub seo_default_twitter_handle: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct HomeCategorySummary { + pub id: i32, + pub name: String, + pub slug: String, + pub count: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct HomePageResponse { + pub site_settings: PublicSiteSettingsResponse, + pub posts: Vec, + pub tags: Vec, + pub friend_links: Vec, + pub categories: Vec, + pub content_overview: crate::services::analytics::ContentAnalyticsOverview, + pub popular_posts: Vec, + pub content_ranges: Vec, } fn normalize_optional_string(value: Option) -> Option { @@ -55,55 +204,386 @@ fn normalize_optional_string(value: Option) -> Option { }) } +fn normalize_optional_int(value: Option, min: i32, max: i32) -> Option { + value.map(|item| item.clamp(min, max)) +} + +fn normalize_string_list(values: Vec) -> Vec { + values + .into_iter() + .filter_map(|item| normalize_optional_string(Some(item))) + .collect() +} + +fn create_ai_provider_id() -> String { + format!("provider-{}", Uuid::new_v4().simple()) +} + +fn default_ai_provider_config() -> AiProviderConfig { + let provider = ai::provider_name(None); + + AiProviderConfig { + id: "default".to_string(), + name: "默认提供商".to_string(), + provider: provider.clone(), + api_base: Some(ai::default_api_base().to_string()), + api_key: Some(ai::default_api_key().to_string()), + chat_model: Some(ai::default_chat_model().to_string()), + image_model: Some(ai::default_image_model_for_provider(&provider).to_string()), + } +} + +fn normalize_ai_provider_configs(items: Vec) -> Vec { + let mut seen_ids = HashSet::new(); + + items + .into_iter() + .enumerate() + .filter_map(|(index, item)| { + let provider = normalize_optional_string(Some(item.provider)) + .unwrap_or_else(|| ai::provider_name(None)); + let api_base = normalize_optional_string(item.api_base); + let api_key = normalize_optional_string(item.api_key); + let chat_model = normalize_optional_string(item.chat_model); + let image_model = normalize_optional_string(item.image_model); + let has_content = !item.name.trim().is_empty() + || !provider.trim().is_empty() + || api_base.is_some() + || api_key.is_some() + || chat_model.is_some() + || image_model.is_some(); + + if !has_content { + return None; + } + + let mut id = + normalize_optional_string(Some(item.id)).unwrap_or_else(create_ai_provider_id); + if !seen_ids.insert(id.clone()) { + id = create_ai_provider_id(); + seen_ids.insert(id.clone()); + } + + let name = normalize_optional_string(Some(item.name)) + .unwrap_or_else(|| format!("提供商 {}", index + 1)); + + Some(AiProviderConfig { + id, + name, + provider, + api_base, + api_key, + chat_model, + image_model, + }) + }) + .collect() +} + +fn legacy_ai_provider_config(model: &Model) -> Option { + let provider = normalize_optional_string(model.ai_provider.clone()); + let api_base = normalize_optional_string(model.ai_api_base.clone()); + let api_key = normalize_optional_string(model.ai_api_key.clone()); + let chat_model = normalize_optional_string(model.ai_chat_model.clone()); + + if provider.is_none() && api_base.is_none() && api_key.is_none() && chat_model.is_none() { + return None; + } + + let normalized_provider = provider.unwrap_or_else(|| ai::provider_name(None)); + + Some(AiProviderConfig { + id: "default".to_string(), + name: "当前提供商".to_string(), + provider: normalized_provider.clone(), + api_base, + api_key, + chat_model, + image_model: Some(ai::default_image_model_for_provider(&normalized_provider).to_string()), + }) +} + +pub(crate) fn ai_provider_configs(model: &Model) -> Vec { + let parsed = model + .ai_providers + .as_ref() + .and_then(|value| serde_json::from_value::>(value.clone()).ok()) + .map(normalize_ai_provider_configs) + .unwrap_or_default(); + + if !parsed.is_empty() { + parsed + } else { + legacy_ai_provider_config(model).into_iter().collect() + } +} + +pub(crate) fn active_ai_provider_id(model: &Model) -> Option { + let configs = ai_provider_configs(model); + let requested = normalize_optional_string(model.ai_active_provider_id.clone()); + + if let Some(active_id) = requested.filter(|id| configs.iter().any(|item| item.id == *id)) { + Some(active_id) + } else { + configs.first().map(|item| item.id.clone()) + } +} + +fn write_ai_provider_state( + model: &mut Model, + configs: Vec, + requested_active_id: Option, +) { + let normalized = normalize_ai_provider_configs(configs); + let active_id = requested_active_id + .filter(|id| normalized.iter().any(|item| item.id == *id)) + .or_else(|| normalized.first().map(|item| item.id.clone())); + + model.ai_providers = (!normalized.is_empty()).then(|| serde_json::json!(normalized.clone())); + model.ai_active_provider_id = active_id.clone(); + + if let Some(active) = active_id.and_then(|id| normalized.into_iter().find(|item| item.id == id)) + { + model.ai_provider = Some(active.provider); + model.ai_api_base = active.api_base; + model.ai_api_key = active.api_key; + model.ai_chat_model = active.chat_model; + } else { + model.ai_provider = None; + model.ai_api_base = None; + model.ai_api_key = None; + model.ai_chat_model = None; + } +} + +fn sync_ai_provider_fields(model: &mut Model) { + write_ai_provider_state( + model, + ai_provider_configs(model), + active_ai_provider_id(model), + ); +} + +fn update_active_provider_from_legacy_fields(model: &mut Model) { + let provider = model.ai_provider.clone(); + let api_base = model.ai_api_base.clone(); + let api_key = model.ai_api_key.clone(); + let chat_model = model.ai_chat_model.clone(); + let mut configs = ai_provider_configs(model); + let active_id = active_ai_provider_id(model); + + if configs.is_empty() { + let mut config = default_ai_provider_config(); + config.provider = provider.unwrap_or_else(|| ai::provider_name(None)); + config.api_base = api_base; + config.api_key = api_key; + config.chat_model = chat_model; + config.image_model = + Some(ai::default_image_model_for_provider(&config.provider).to_string()); + write_ai_provider_state( + model, + vec![config], + Some(active_id.unwrap_or_else(|| "default".to_string())), + ); + return; + } + + let target_id = active_id + .clone() + .or_else(|| configs.first().map(|item| item.id.clone())); + + if let Some(target_id) = target_id { + for config in &mut configs { + if config.id == target_id { + if let Some(next_provider) = provider.clone() { + config.provider = next_provider; + } + config.api_base = api_base.clone(); + config.api_key = api_key.clone(); + config.chat_model = chat_model.clone(); + if config.image_model.is_none() { + config.image_model = + Some(ai::default_image_model_for_provider(&config.provider).to_string()); + } + } + } + } + + write_ai_provider_state(model, configs, active_id); +} + +fn normalize_music_playlist(items: Vec) -> Vec { + items + .into_iter() + .map(|item| MusicTrackPayload { + title: item.title.trim().to_string(), + artist: normalize_optional_string(item.artist), + album: normalize_optional_string(item.album), + url: item.url.trim().to_string(), + cover_image_url: normalize_optional_string(item.cover_image_url), + accent_color: normalize_optional_string(item.accent_color), + description: normalize_optional_string(item.description), + }) + .filter(|item| !item.title.is_empty() && !item.url.is_empty()) + .collect() +} + impl SiteSettingsPayload { - fn apply(self, item: &mut ActiveModel) { + pub(crate) fn apply(self, item: &mut Model) { if let Some(site_name) = self.site_name { - item.site_name = Set(normalize_optional_string(Some(site_name))); + item.site_name = normalize_optional_string(Some(site_name)); } if let Some(site_short_name) = self.site_short_name { - item.site_short_name = Set(normalize_optional_string(Some(site_short_name))); + item.site_short_name = normalize_optional_string(Some(site_short_name)); } if let Some(site_url) = self.site_url { - item.site_url = Set(normalize_optional_string(Some(site_url))); + item.site_url = normalize_optional_string(Some(site_url)); } if let Some(site_title) = self.site_title { - item.site_title = Set(normalize_optional_string(Some(site_title))); + item.site_title = normalize_optional_string(Some(site_title)); } if let Some(site_description) = self.site_description { - item.site_description = Set(normalize_optional_string(Some(site_description))); + item.site_description = normalize_optional_string(Some(site_description)); } if let Some(hero_title) = self.hero_title { - item.hero_title = Set(normalize_optional_string(Some(hero_title))); + item.hero_title = normalize_optional_string(Some(hero_title)); } if let Some(hero_subtitle) = self.hero_subtitle { - item.hero_subtitle = Set(normalize_optional_string(Some(hero_subtitle))); + item.hero_subtitle = normalize_optional_string(Some(hero_subtitle)); } if let Some(owner_name) = self.owner_name { - item.owner_name = Set(normalize_optional_string(Some(owner_name))); + item.owner_name = normalize_optional_string(Some(owner_name)); } if let Some(owner_title) = self.owner_title { - item.owner_title = Set(normalize_optional_string(Some(owner_title))); + item.owner_title = normalize_optional_string(Some(owner_title)); } if let Some(owner_bio) = self.owner_bio { - item.owner_bio = Set(normalize_optional_string(Some(owner_bio))); + item.owner_bio = normalize_optional_string(Some(owner_bio)); } if let Some(owner_avatar_url) = self.owner_avatar_url { - item.owner_avatar_url = Set(normalize_optional_string(Some(owner_avatar_url))); + item.owner_avatar_url = normalize_optional_string(Some(owner_avatar_url)); } if let Some(social_github) = self.social_github { - item.social_github = Set(normalize_optional_string(Some(social_github))); + item.social_github = normalize_optional_string(Some(social_github)); } if let Some(social_twitter) = self.social_twitter { - item.social_twitter = Set(normalize_optional_string(Some(social_twitter))); + item.social_twitter = normalize_optional_string(Some(social_twitter)); } if let Some(social_email) = self.social_email { - item.social_email = Set(normalize_optional_string(Some(social_email))); + item.social_email = normalize_optional_string(Some(social_email)); } if let Some(location) = self.location { - item.location = Set(normalize_optional_string(Some(location))); + item.location = normalize_optional_string(Some(location)); } if let Some(tech_stack) = self.tech_stack { - item.tech_stack = Set(Some(serde_json::json!(tech_stack))); + item.tech_stack = Some(serde_json::json!(tech_stack)); + } + if let Some(music_playlist) = self.music_playlist { + item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist))); + } + if let Some(ai_enabled) = self.ai_enabled { + item.ai_enabled = Some(ai_enabled); + } + if let Some(paragraph_comments_enabled) = self.paragraph_comments_enabled { + item.paragraph_comments_enabled = Some(paragraph_comments_enabled); + } + let provider_list_supplied = self.ai_providers.is_some(); + let provided_ai_providers = self.ai_providers.map(normalize_ai_provider_configs); + let requested_active_provider_id = self + .ai_active_provider_id + .and_then(|value| normalize_optional_string(Some(value))); + let legacy_provider_fields_updated = self.ai_provider.is_some() + || self.ai_api_base.is_some() + || self.ai_api_key.is_some() + || self.ai_chat_model.is_some(); + if let Some(ai_provider) = self.ai_provider { + item.ai_provider = normalize_optional_string(Some(ai_provider)); + } + if let Some(ai_api_base) = self.ai_api_base { + item.ai_api_base = normalize_optional_string(Some(ai_api_base)); + } + if let Some(ai_api_key) = self.ai_api_key { + item.ai_api_key = normalize_optional_string(Some(ai_api_key)); + } + if let Some(ai_chat_model) = self.ai_chat_model { + item.ai_chat_model = normalize_optional_string(Some(ai_chat_model)); + } + if let Some(ai_image_provider) = self.ai_image_provider { + item.ai_image_provider = normalize_optional_string(Some(ai_image_provider)); + } + if let Some(ai_image_api_base) = self.ai_image_api_base { + item.ai_image_api_base = normalize_optional_string(Some(ai_image_api_base)); + } + if let Some(ai_image_api_key) = self.ai_image_api_key { + item.ai_image_api_key = normalize_optional_string(Some(ai_image_api_key)); + } + if let Some(ai_image_model) = self.ai_image_model { + item.ai_image_model = normalize_optional_string(Some(ai_image_model)); + } + if let Some(ai_embedding_model) = self.ai_embedding_model { + item.ai_embedding_model = normalize_optional_string(Some(ai_embedding_model)); + } + if let Some(ai_system_prompt) = self.ai_system_prompt { + item.ai_system_prompt = normalize_optional_string(Some(ai_system_prompt)); + } + if self.ai_top_k.is_some() { + item.ai_top_k = normalize_optional_int(self.ai_top_k, 1, 12); + } + if self.ai_chunk_size.is_some() { + item.ai_chunk_size = normalize_optional_int(self.ai_chunk_size, 400, 4000); + } + if let Some(media_r2_account_id) = self.media_r2_account_id { + item.media_r2_account_id = normalize_optional_string(Some(media_r2_account_id)); + } + if let Some(media_storage_provider) = self.media_storage_provider { + item.media_storage_provider = normalize_optional_string(Some(media_storage_provider)); + } + if let Some(media_r2_bucket) = self.media_r2_bucket { + item.media_r2_bucket = normalize_optional_string(Some(media_r2_bucket)); + } + if let Some(media_r2_public_base_url) = self.media_r2_public_base_url { + item.media_r2_public_base_url = + normalize_optional_string(Some(media_r2_public_base_url)); + } + if let Some(media_r2_access_key_id) = self.media_r2_access_key_id { + item.media_r2_access_key_id = normalize_optional_string(Some(media_r2_access_key_id)); + } + if let Some(media_r2_secret_access_key) = self.media_r2_secret_access_key { + item.media_r2_secret_access_key = + normalize_optional_string(Some(media_r2_secret_access_key)); + } + if let Some(seo_default_og_image) = self.seo_default_og_image { + item.seo_default_og_image = normalize_optional_string(Some(seo_default_og_image)); + } + if let Some(seo_default_twitter_handle) = self.seo_default_twitter_handle { + item.seo_default_twitter_handle = + normalize_optional_string(Some(seo_default_twitter_handle)); + } + if let Some(notification_webhook_url) = self.notification_webhook_url { + item.notification_webhook_url = + normalize_optional_string(Some(notification_webhook_url)); + } + if let Some(notification_comment_enabled) = self.notification_comment_enabled { + item.notification_comment_enabled = Some(notification_comment_enabled); + } + if let Some(notification_friend_link_enabled) = self.notification_friend_link_enabled { + item.notification_friend_link_enabled = Some(notification_friend_link_enabled); + } + if let Some(search_synonyms) = self.search_synonyms { + let normalized = normalize_string_list(search_synonyms); + item.search_synonyms = (!normalized.is_empty()).then(|| serde_json::json!(normalized)); + } + + if provider_list_supplied { + write_ai_provider_state( + item, + provided_ai_providers.unwrap_or_default(), + requested_active_provider_id.or_else(|| item.ai_active_provider_id.clone()), + ); + } else if legacy_provider_fields_updated { + update_active_provider_from_legacy_fields(item); + } else { + sync_ai_provider_fields(item); } } } @@ -112,32 +592,103 @@ fn default_payload() -> SiteSettingsPayload { SiteSettingsPayload { site_name: Some("InitCool".to_string()), site_short_name: Some("Termi".to_string()), - site_url: Some("https://termi.dev".to_string()), + site_url: Some("https://init.cool".to_string()), site_title: Some("InitCool - 终端风格的内容平台".to_string()), site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活。".to_string()), hero_title: Some("欢迎来到我的极客终端博客".to_string()), hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()), owner_name: Some("InitCool".to_string()), - owner_title: Some("前端开发者 / 技术博主".to_string()), + owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()), owner_bio: Some( - "一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。" + "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。" .to_string(), ), - owner_avatar_url: None, - social_github: Some("https://github.com".to_string()), - social_twitter: Some("https://twitter.com".to_string()), - social_email: Some("mailto:hello@termi.dev".to_string()), + owner_avatar_url: Some("https://github.com/limitcool.png".to_string()), + social_github: Some("https://github.com/limitcool".to_string()), + social_twitter: None, + social_email: Some("mailto:initcoool@gmail.com".to_string()), location: Some("Hong Kong".to_string()), tech_stack: Some(vec![ - "Astro".to_string(), + "Rust".to_string(), + "Go".to_string(), + "Python".to_string(), "Svelte".to_string(), - "Tailwind CSS".to_string(), - "TypeScript".to_string(), + "Astro".to_string(), + "Loco.rs".to_string(), ]), + music_playlist: Some(vec![ + MusicTrackPayload { + title: "山中来信".to_string(), + artist: Some("InitCool Radio".to_string()), + album: Some("站点默认歌单".to_string()), + url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3".to_string(), + cover_image_url: Some( + "https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80" + .to_string(), + ), + accent_color: Some("#2f6b5f".to_string()), + description: Some("适合文章阅读时循环播放的轻氛围曲。".to_string()), + }, + MusicTrackPayload { + title: "风吹松声".to_string(), + artist: Some("InitCool Radio".to_string()), + album: Some("站点默认歌单".to_string()), + url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3".to_string(), + cover_image_url: Some( + "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80" + .to_string(), + ), + accent_color: Some("#8a5b35".to_string()), + description: Some("偏木质感的器乐氛围,适合深夜浏览。".to_string()), + }, + MusicTrackPayload { + title: "夜航小记".to_string(), + artist: Some("InitCool Radio".to_string()), + album: Some("站点默认歌单".to_string()), + url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3".to_string(), + cover_image_url: Some( + "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80" + .to_string(), + ), + accent_color: Some("#375a7f".to_string()), + description: Some("节奏更明显一点,适合切换阅读状态。".to_string()), + }, + ]), + ai_enabled: Some(false), + paragraph_comments_enabled: Some(true), + ai_provider: Some(ai::provider_name(None)), + ai_api_base: Some(ai::default_api_base().to_string()), + ai_api_key: Some(ai::default_api_key().to_string()), + ai_chat_model: Some(ai::default_chat_model().to_string()), + ai_image_provider: None, + ai_image_api_base: None, + ai_image_api_key: None, + ai_image_model: None, + ai_providers: Some(vec![default_ai_provider_config()]), + ai_active_provider_id: Some("default".to_string()), + ai_embedding_model: Some(ai::local_embedding_label().to_string()), + ai_system_prompt: Some( + "你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。" + .to_string(), + ), + ai_top_k: Some(4), + ai_chunk_size: Some(1200), + media_storage_provider: None, + media_r2_account_id: None, + media_r2_bucket: None, + media_r2_public_base_url: None, + media_r2_access_key_id: None, + media_r2_secret_access_key: None, + seo_default_og_image: None, + seo_default_twitter_handle: None, + notification_webhook_url: None, + notification_comment_enabled: Some(false), + notification_friend_link_enabled: Some(false), + search_synonyms: Some(Vec::new()), } } -async fn load_current(ctx: &AppContext) -> Result { +pub(crate) async fn load_current(ctx: &AppContext) -> Result { if let Some(settings) = Entity::find() .order_by_asc(site_settings::Column::Id) .one(&ctx.db) @@ -146,33 +697,132 @@ async fn load_current(ctx: &AppContext) -> Result { return Ok(settings); } - let mut item = ActiveModel { + let inserted = ActiveModel { id: Set(1), ..Default::default() - }; - default_payload().apply(&mut item); - Ok(item.insert(&ctx.db).await?) + } + .insert(&ctx.db) + .await?; + let mut model = inserted; + default_payload().apply(&mut model); + Ok(model + .into_active_model() + .reset_all() + .update(&ctx.db) + .await?) +} + +fn public_response(model: Model) -> PublicSiteSettingsResponse { + PublicSiteSettingsResponse { + id: model.id, + site_name: model.site_name, + site_short_name: model.site_short_name, + site_url: model.site_url, + site_title: model.site_title, + site_description: model.site_description, + hero_title: model.hero_title, + hero_subtitle: model.hero_subtitle, + owner_name: model.owner_name, + owner_title: model.owner_title, + owner_bio: model.owner_bio, + owner_avatar_url: model.owner_avatar_url, + social_github: model.social_github, + social_twitter: model.social_twitter, + social_email: model.social_email, + location: model.location, + tech_stack: model.tech_stack, + music_playlist: model.music_playlist, + ai_enabled: model.ai_enabled.unwrap_or(false), + paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true), + seo_default_og_image: model.seo_default_og_image, + seo_default_twitter_handle: model.seo_default_twitter_handle, + } +} + +#[debug_handler] +pub async fn home(State(ctx): State) -> Result { + content::sync_markdown_posts(&ctx).await?; + + let site_settings = public_response(load_current(&ctx).await?); + let posts = posts::Entity::find() + .order_by_desc(posts::Column::CreatedAt) + .all(&ctx.db) + .await? + .into_iter() + .filter(|post| content::is_post_listed_publicly(post, chrono::Utc::now().fixed_offset())) + .collect::>(); + let tags = tags::Entity::find().all(&ctx.db).await?; + let friend_links = friend_links::Entity::find() + .filter(friend_links::Column::Status.eq("approved")) + .order_by_desc(friend_links::Column::CreatedAt) + .all(&ctx.db) + .await?; + let category_items = categories::Entity::find() + .order_by_asc(categories::Column::Slug) + .all(&ctx.db) + .await?; + + let categories = category_items + .into_iter() + .map(|category| { + let name = category + .name + .clone() + .unwrap_or_else(|| category.slug.clone()); + let count = posts + .iter() + .filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str())) + .count(); + + HomeCategorySummary { + id: category.id, + name, + slug: category.slug, + count, + } + }) + .collect::>(); + let content_highlights = + crate::services::analytics::build_public_content_highlights(&ctx, &posts).await?; + let content_ranges = crate::services::analytics::build_public_content_windows(&ctx, &posts).await?; + + format::json(HomePageResponse { + site_settings, + posts, + tags, + friend_links, + categories, + content_overview: content_highlights.overview, + popular_posts: content_highlights.popular_posts, + content_ranges, + }) } #[debug_handler] pub async fn show(State(ctx): State) -> Result { - format::json(load_current(&ctx).await?) + format::json(public_response(load_current(&ctx).await?)) } #[debug_handler] pub async fn update( + headers: HeaderMap, State(ctx): State, Json(params): Json, ) -> Result { + check_auth(&headers)?; + let current = load_current(&ctx).await?; - let mut item = current.into_active_model(); + let mut item = current; params.apply(&mut item); - format::json(item.update(&ctx.db).await?) + let item = item.into_active_model().reset_all(); + let updated = item.update(&ctx.db).await?; + format::json(public_response(updated)) } pub fn routes() -> Routes { Routes::new() .prefix("api/site_settings/") + .add("home", get(home)) .add("/", get(show)) .add("/", put(update)) .add("/", patch(update)) diff --git a/backend/src/controllers/subscription.rs b/backend/src/controllers/subscription.rs new file mode 100644 index 0000000..894d501 --- /dev/null +++ b/backend/src/controllers/subscription.rs @@ -0,0 +1,202 @@ +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::services::{abuse_guard, admin_audit, subscriptions}; + +#[derive(Clone, Debug, Deserialize)] +pub struct PublicSubscriptionPayload { + pub email: String, + #[serde(default, alias = "displayName")] + pub display_name: Option, + #[serde(default)] + pub source: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SubscriptionTokenPayload { + pub token: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SubscriptionManageQuery { + pub token: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SubscriptionManageUpdatePayload { + pub token: String, + #[serde(default, alias = "displayName")] + pub display_name: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub filters: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicSubscriptionResponse { + pub ok: bool, + pub subscription_id: i32, + pub status: String, + pub requires_confirmation: bool, + pub message: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct SubscriptionManageResponse { + pub ok: bool, + pub subscription: subscriptions::PublicSubscriptionView, +} + +fn public_subscription_metadata(source: Option) -> serde_json::Value { + serde_json::json!({ + "source": source, + "kind": "public-form", + }) +} + +#[debug_handler] +pub async fn subscribe( + State(ctx): State, + headers: axum::http::HeaderMap, + Json(payload): Json, +) -> Result { + let email = payload.email.trim().to_ascii_lowercase(); + abuse_guard::enforce_public_scope( + "subscription", + abuse_guard::detect_client_ip(&headers).as_deref(), + Some(&email), + )?; + + let result = subscriptions::create_public_email_subscription( + &ctx, + &email, + payload.display_name, + Some(public_subscription_metadata(payload.source)), + ) + .await?; + + admin_audit::log_event( + &ctx, + None, + if result.requires_confirmation { + "subscription.public.pending" + } else { + "subscription.public.active" + }, + "subscription", + Some(result.subscription.id.to_string()), + Some(result.subscription.target.clone()), + Some(serde_json::json!({ + "channel_type": result.subscription.channel_type, + "status": result.subscription.status, + })), + ) + .await?; + + format::json(PublicSubscriptionResponse { + ok: true, + subscription_id: result.subscription.id, + status: result.subscription.status, + requires_confirmation: result.requires_confirmation, + message: result.message, + }) +} + +#[debug_handler] +pub async fn confirm( + State(ctx): State, + Json(payload): Json, +) -> Result { + let item = subscriptions::confirm_subscription(&ctx, &payload.token).await?; + admin_audit::log_event( + &ctx, + None, + "subscription.public.confirm", + "subscription", + Some(item.id.to_string()), + Some(item.target.clone()), + Some(serde_json::json!({ "channel_type": item.channel_type })), + ) + .await?; + + format::json(SubscriptionManageResponse { + ok: true, + subscription: subscriptions::to_public_subscription_view(&item), + }) +} + +#[debug_handler] +pub async fn manage( + State(ctx): State, + Query(query): Query, +) -> Result { + let item = subscriptions::get_subscription_by_manage_token(&ctx, &query.token).await?; + format::json(SubscriptionManageResponse { + ok: true, + subscription: subscriptions::to_public_subscription_view(&item), + }) +} + +#[debug_handler] +pub async fn update_manage( + State(ctx): State, + Json(payload): Json, +) -> Result { + let item = subscriptions::update_subscription_preferences( + &ctx, + &payload.token, + payload.display_name, + payload.status, + payload.filters, + ) + .await?; + + admin_audit::log_event( + &ctx, + None, + "subscription.public.update", + "subscription", + Some(item.id.to_string()), + Some(item.target.clone()), + None, + ) + .await?; + + format::json(SubscriptionManageResponse { + ok: true, + subscription: subscriptions::to_public_subscription_view(&item), + }) +} + +#[debug_handler] +pub async fn unsubscribe( + State(ctx): State, + Json(payload): Json, +) -> Result { + let item = subscriptions::unsubscribe_subscription(&ctx, &payload.token).await?; + admin_audit::log_event( + &ctx, + None, + "subscription.public.unsubscribe", + "subscription", + Some(item.id.to_string()), + Some(item.target.clone()), + None, + ) + .await?; + + format::json(SubscriptionManageResponse { + ok: true, + subscription: subscriptions::to_public_subscription_view(&item), + }) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("/api/subscriptions") + .add("/", post(subscribe)) + .add("/confirm", post(confirm)) + .add("/manage", get(manage).patch(update_manage)) + .add("/unsubscribe", post(unsubscribe)) +} diff --git a/backend/src/controllers/tag.rs b/backend/src/controllers/tag.rs index 1e75473..e73d547 100644 --- a/backend/src/controllers/tag.rs +++ b/backend/src/controllers/tag.rs @@ -48,15 +48,42 @@ pub async fn update( Json(params): Json, ) -> Result { let item = load_item(&ctx, id).await?; + let previous_name = item.name.clone(); + let previous_slug = item.slug.clone(); + let next_name = params + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + if let Some(next_name) = next_name { + if previous_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + != Some(next_name) + { + content::rewrite_tag_references( + previous_name.as_deref(), + &previous_slug, + Some(next_name), + )?; + } + } + let mut item = item.into_active_model(); params.update(&mut item); let item = item.update(&ctx.db).await?; + content::sync_markdown_posts(&ctx).await?; format::json(item) } #[debug_handler] pub async fn remove(Path(id): Path, State(ctx): State) -> Result { - load_item(&ctx, id).await?.delete(&ctx.db).await?; + let item = load_item(&ctx, id).await?; + content::rewrite_tag_references(item.name.as_deref(), &item.slug, None)?; + item.delete(&ctx.db).await?; + content::sync_markdown_posts(&ctx).await?; format::empty() } diff --git a/backend/src/fixtures/comments.yaml b/backend/src/fixtures/comments.yaml index d2c4931..948fa75 100644 --- a/backend/src/fixtures/comments.yaml +++ b/backend/src/fixtures/comments.yaml @@ -1,48 +1,48 @@ - id: 1 pid: 1 - author: "Alice" - email: "alice@example.com" - content: "Great introduction! Looking forward to more content." + author: "林川" + email: "linchuan@example.com" + content: "这篇做长文测试很合适,段落密度和古文节奏都不错。" approved: true - id: 2 pid: 1 - author: "Bob" - email: "bob@example.com" - content: "The terminal UI looks amazing. Love the design!" + author: "阿青" + email: "aqing@example.com" + content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。" approved: true - id: 3 pid: 2 - author: "Charlie" - email: "charlie@example.com" - content: "Thanks for the Rust tips! The ownership concept finally clicked for me." + author: "周宁" + email: "zhouling@example.com" + content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。" approved: true - id: 4 pid: 3 - author: "Diana" - email: "diana@example.com" - content: "Astro is indeed fast. I've been using it for my personal blog too." + author: "顾远" + email: "guyuan@example.com" + content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。" approved: true - id: 5 pid: 4 - author: "Eve" - email: "eve@example.com" - content: "The color palette you shared is perfect. Using it for my terminal theme now!" + author: "清嘉" + email: "qingjia@example.com" + content: "黄山记的序文很适合测试首屏摘要生成。" approved: true - id: 6 pid: 5 - author: "Frank" - email: "frank@example.com" - content: "Loco.rs looks promising. Might use it for my next project." + author: "石霁" + email: "shiji@example.com" + content: "想看看评测页和文章页共存时,搜索能不能把这类古文结果排在前面。" approved: false - id: 7 - pid: 2 - author: "Grace" - email: "grace@example.com" - content: "Would love to see more advanced Rust patterns in future posts." + pid: 3 + author: "江禾" + email: "jianghe@example.com" + content: "如果后续要做段落评论,这篇恒山记很适合,因为章节分段比较清晰。" approved: true diff --git a/backend/src/fixtures/friend_links.yaml b/backend/src/fixtures/friend_links.yaml index 50e9946..b1964af 100644 --- a/backend/src/fixtures/friend_links.yaml +++ b/backend/src/fixtures/friend_links.yaml @@ -1,38 +1,38 @@ - id: 1 - site_name: "Tech Blog Daily" - site_url: "https://techblog.example.com" - avatar_url: "https://techblog.example.com/avatar.png" - description: "Daily tech news and tutorials" - category: "tech" + site_name: "山中札记" + site_url: "https://mountain-notes.example.com" + avatar_url: "https://mountain-notes.example.com/avatar.png" + description: "记录古籍、游记与自然地理的中文内容站。" + category: "文化" status: "approved" - id: 2 - site_name: "Rustacean Station" - site_url: "https://rustacean.example.com" - avatar_url: "https://rustacean.example.com/logo.png" - description: "All things Rust programming" - category: "tech" + site_name: "旧书与远方" + site_url: "https://oldbooks.example.com" + avatar_url: "https://oldbooks.example.com/logo.png" + description: "分享古典文学、读书笔记和旅行随笔。" + category: "阅读" status: "approved" - id: 3 - site_name: "Design Patterns" - site_url: "https://designpatterns.example.com" - avatar_url: "https://designpatterns.example.com/icon.png" - description: "UI/UX design inspiration" - category: "design" + site_name: "山海数据局" + site_url: "https://shanhai-data.example.com" + avatar_url: "https://shanhai-data.example.com/icon.png" + description: "偏技术向的中文站点,关注搜索、知识库与可视化。" + category: "技术" status: "approved" - id: 4 - site_name: "Code Snippets" - site_url: "https://codesnippets.example.com" - description: "Useful code snippets for developers" - category: "dev" + site_name: "风物手册" + site_url: "https://fengwu.example.com" + description: "整理地方风物、古迹与旅行地图。" + category: "旅行" status: "pending" - id: 5 - site_name: "Web Dev Weekly" - site_url: "https://webdevweekly.example.com" - avatar_url: "https://webdevweekly.example.com/favicon.png" - description: "Weekly web development newsletter" - category: "dev" + site_name: "慢读周刊" + site_url: "https://slowread.example.com" + avatar_url: "https://slowread.example.com/favicon.png" + description: "每周推荐中文长文、读书摘录与站点发现。" + category: "内容" status: "pending" diff --git a/backend/src/fixtures/posts.yaml b/backend/src/fixtures/posts.yaml index 75295c4..74a0c78 100644 --- a/backend/src/fixtures/posts.yaml +++ b/backend/src/fixtures/posts.yaml @@ -1,191 +1,109 @@ - id: 1 pid: 1 - title: "Welcome to Termi Blog" + title: "徐霞客游记·游太和山日记(上)" slug: "welcome-to-termi" content: | - # Welcome to Termi Blog + # 徐霞客游记·游太和山日记(上) - This is the first post on our new blog built with Astro and Loco.rs backend. + 登仙猿岭。十馀里,有枯溪小桥,为郧县境,乃河南、湖广界。东五里,有池一泓,曰青泉,上源不见所自来,而下流淙淙,地又属淅川。 - ## Features + 自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。 - - 🚀 Fast performance with Astro - - 🎨 Terminal-style UI design - - 💬 Comments system - - 🔗 Friend links - - 🏷️ Tags and categories - - ## Code Example - - ```rust - fn main() { - println!("Hello, Termi!"); - } - ``` - - Stay tuned for more posts! - excerpt: "Welcome to our new blog built with Astro and Loco.rs backend." - category: "general" + 骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。 + excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。" + category: "古籍游记" published: true pinned: true tags: - - welcome - - astro - - loco-rs + - 徐霞客 + - 游记 + - 太和山 + - 长文测试 - id: 2 pid: 2 - title: "Rust Programming Tips" - slug: "rust-programming-tips" + title: "徐霞客游记·游太和山日记(下)" + slug: "building-blog-with-astro" content: | - # Rust Programming Tips + # 徐霞客游记·游太和山日记(下) - Here are some essential tips for Rust developers: + 更衣上金顶。瞻叩毕,天宇澄朗,下瞰诸峰,近者鹄峙,远者罗列,诚天真奥区也。 - ## 1. Ownership and Borrowing + 遂从三天门之右小径下峡中。此径无级无索,乱峰离立,路穿其间,迥觉幽胜。三里馀,抵蜡烛峰右,泉涓涓溢出路旁,下为蜡烛涧。 - Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time. - - ## 2. Pattern Matching - - Use `match` expressions for exhaustive pattern matching: - - ```rust - match result { - Ok(value) => println!("Success: {}", value), - Err(e) => println!("Error: {}", e), - } - ``` - - ## 3. Error Handling - - Use `Result` and `Option` types effectively with the `?` operator. - - Happy coding! - excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling." - category: "tech" + 从宫左趋雷公洞。洞在悬崖间。乃从北天门下,一径阴森,滴水、仙侣二岩,俱在路左,飞崖上突,泉滴沥于中。 + excerpt: "《徐霞客游记》太和山下篇,包含琼台、南岩与五龙宫等段落。" + category: "古籍游记" published: true pinned: false tags: - - rust - - programming - - tips + - 徐霞客 + - 游记 + - 太和山 + - 长文测试 - id: 3 pid: 3 - title: "Building a Blog with Astro" - slug: "building-blog-with-astro" + title: "徐霞客游记·游恒山日记" + slug: "rust-programming-tips" content: | - # Building a Blog with Astro + # 徐霞客游记·游恒山日记 - Astro is a modern static site generator that delivers lightning-fast performance. + 出南山。大溪从山中俱来者,别而西去。余北驰平陆中,望外界之山,高不及台山十之四,其长缭绕如垣。 - ## Why Astro? + 余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。 - - **Zero JavaScript by default**: Ships less JavaScript to the client - - **Island Architecture**: Hydrate only interactive components - - **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS - - **Great DX**: Excellent developer experience with hot module replacement - - ## Getting Started - - ```bash - npm create astro@latest - cd my-astro-project - npm install - npm run dev - ``` - - ## Conclusion - - Astro is perfect for content-focused websites like blogs. - excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs." - category: "tech" + 三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。 + excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。" + category: "古籍游记" published: true pinned: false tags: - - astro - - web-dev - - static-site + - 徐霞客 + - 恒山 + - 悬空寺 + - 长文测试 - id: 4 pid: 4 - title: "Terminal UI Design Principles" + title: "游黄山记(上)" slug: "terminal-ui-design" content: | - # Terminal UI Design Principles + # 游黄山记(上) - Terminal-style interfaces are making a comeback in modern web design. + 辛巳春,余与程孟阳订黄山之游,约以梅花时相寻于武林之西溪。徐维翰书来劝驾,读之两腋欲举,遂挟吴去尘以行。 - ## Key Elements + 黄山耸秀峻极,作镇一方。江南诸山,天台、天目为最,以地形准之,黄山之趾与二山齐。 - 1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono - 2. **Dark Themes**: Black or dark backgrounds with vibrant text colors - 3. **Command Prompts**: Use `$` or `>` as visual indicators - 4. **ASCII Art**: Decorative elements using text characters - 5. **Blinking Cursor**: The iconic terminal cursor - - ## Color Palette - - - Background: `#0d1117` - - Text: `#c9d1d9` - - Accent: `#58a6ff` - - Success: `#3fb950` - - Warning: `#d29922` - - Error: `#f85149` - - ## Implementation - - Use CSS to create the terminal aesthetic while maintaining accessibility. - excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces." - category: "design" + 自山口至汤口,山之麓也,登山之径于是始。汤泉之流,自紫石峰六百仞县布,其下有香泉溪。 + excerpt: "钱谦益《游黄山记》上篇,包含序、记之一与记之二。" + category: "古籍游记" published: true pinned: false tags: - - design - - terminal - - ui + - 钱谦益 + - 黄山 + - 游记 + - 长文测试 - id: 5 pid: 5 - title: "Loco.rs Backend Framework" + title: "游黄山记(中)" slug: "loco-rs-framework" content: | - # Introduction to Loco.rs + # 游黄山记(中) - Loco.rs is a web and API framework for Rust inspired by Rails. + 由祥符寺度石桥而北,逾慈光寺,行数里,径朱砂庵而上。过此取道钵盂、老人两峰之间,峰趾相并,两崖合遝,弥望削成。 - ## Features + 憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。 - - **MVC Architecture**: Model-View-Controller pattern - - **SeaORM Integration**: Powerful ORM for database operations - - **Background Jobs**: Built-in job processing - - **Authentication**: Ready-to-use auth system - - **CLI Generator**: Scaffold resources quickly - - ## Quick Start - - ```bash - cargo install loco - loco new myapp - cd myapp - cargo loco start - ``` - - ## Why Loco.rs? - - - Opinionated but flexible - - Production-ready defaults - - Excellent documentation - - Active community - - Perfect for building APIs and web applications in Rust. - excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust." - category: "tech" + 清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。 + excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。" + category: "古籍游记" published: true pinned: false tags: - - rust - - loco-rs - - backend - - api + - 钱谦益 + - 黄山 + - 游记 + - 长文测试 diff --git a/backend/src/fixtures/reviews.yaml b/backend/src/fixtures/reviews.yaml index 73f63e9..b5d2192 100644 --- a/backend/src/fixtures/reviews.yaml +++ b/backend/src/fixtures/reviews.yaml @@ -1,59 +1,59 @@ - id: 1 - title: "塞尔达传说:王国之泪" - review_type: "game" - rating: 5 - review_date: "2024-03-20" - status: "completed" - description: "开放世界的巅峰之作,究极手能力带来无限创意空间" - tags: ["Switch", "开放世界", "冒险"] - cover: "🎮" - -- id: 2 - title: "进击的巨人" - review_type: "anime" - rating: 5 - review_date: "2023-11-10" - status: "completed" - description: "史诗级完结,剧情反转令人震撼" - tags: ["热血", "悬疑", "神作"] - cover: "🎭" - -- id: 3 - title: "赛博朋克 2077" - review_type: "game" - rating: 4 - review_date: "2024-01-15" - status: "completed" - description: "夜之城的故事,虽然首发有问题但后续更新很棒" - tags: ["PC", "RPG", "科幻"] - cover: "🎮" - -- id: 4 - title: "三体" - review_type: "book" - rating: 5 - review_date: "2023-08-05" - status: "completed" - description: "硬科幻巅峰,宇宙社会学的黑暗森林法则" - tags: ["科幻", "经典", "雨果奖"] - cover: "📚" - -- id: 5 - title: "星际穿越" + title: "《漫长的季节》" review_type: "movie" rating: 5 - review_date: "2024-02-14" - status: "completed" - description: "诺兰神作,五维空间和黑洞的视觉奇观" - tags: ["科幻", "IMAX", "诺兰"] - cover: "🎬" + review_date: "2024-03-20" + status: "published" + description: "极有质感的中文悬疑剧,人物命运与时代氛围都很扎实。" + tags: ["国产剧", "悬疑", "年度推荐"] + cover: "/review-covers/the-long-season.svg" -- id: 6 - title: "博德之门3" +- id: 2 + title: "《十三邀》" + review_type: "movie" + rating: 4 + review_date: "2024-01-10" + status: "published" + description: "更像一组人物观察样本,适合慢慢看,不适合倍速。" + tags: ["访谈", "人文", "纪实"] + cover: "/review-covers/thirteen-invites.svg" + +- id: 3 + title: "《黑神话:悟空》" review_type: "game" rating: 5 - review_date: "2024-04-01" - status: "in-progress" - description: "CRPG的文艺复兴,骰子决定命运" - tags: ["PC", "CRPG", "多人"] - cover: "🎮" + review_date: "2024-08-25" + status: "published" + description: "美术和演出都很强,战斗手感也足够扎实,是非常好的中文游戏样本。" + tags: ["国产游戏", "动作", "神话"] + cover: "/review-covers/black-myth-wukong.svg" + +- id: 4 + title: "《置身事内》" + review_type: "book" + rating: 5 + review_date: "2024-02-18" + status: "published" + description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。" + tags: ["经济", "非虚构", "中国"] + cover: "/review-covers/placed-within.svg" + +- id: 5 + title: "《宇宙探索编辑部》" + review_type: "movie" + rating: 4 + review_date: "2024-04-12" + status: "published" + description: "荒诞和真诚并存,气质很特别,很适合作为中文评论内容。" + tags: ["电影", "科幻", "荒诞"] + cover: "/review-covers/journey-to-the-west-editorial.svg" + +- id: 6 + title: "《疲惫生活中的英雄梦想》" + review_type: "music" + rating: 4 + review_date: "2024-05-01" + status: "draft" + description: "适合深夜循环,文字和旋律都带一点诚恳的钝感。" + tags: ["音乐", "中文", "独立"] + cover: "/review-covers/hero-dreams-in-tired-life.svg" diff --git a/backend/src/fixtures/site_settings.yaml b/backend/src/fixtures/site_settings.yaml index 4f9adbd..b30d822 100644 --- a/backend/src/fixtures/site_settings.yaml +++ b/backend/src/fixtures/site_settings.yaml @@ -1,21 +1,55 @@ - id: 1 site_name: "InitCool" site_short_name: "Termi" - site_url: "https://termi.dev" - site_title: "InitCool - 终端风格的内容平台" - site_description: "一个基于终端美学的个人内容站,记录代码、设计和生活。" - hero_title: "欢迎来到我的极客终端博客" - hero_subtitle: "这里记录技术、代码和生活点滴" + site_url: "https://init.cool" + site_title: "InitCool · 中文长文与 AI 搜索实验站" + site_description: "一个偏终端审美的中文内容站,用来测试文章检索、AI 问答、段落评论与后台工作流。" + hero_title: "欢迎来到我的中文内容实验站" + hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验" owner_name: "InitCool" - owner_title: "前端开发者 / 技术博主" - owner_bio: "一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。" - owner_avatar_url: "" - social_github: "https://github.com" - social_twitter: "https://twitter.com" - social_email: "mailto:hello@termi.dev" - location: "Hong Kong" + owner_title: "Rust / Go / Python Developer · Builder @ init.cool" + owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。" + owner_avatar_url: "https://github.com/limitcool.png" + social_github: "https://github.com/limitcool" + social_twitter: "" + social_email: "mailto:initcoool@gmail.com" + location: "中国香港" tech_stack: - - "Astro" + - "Rust" + - "Go" + - "Python" - "Svelte" - - "Tailwind CSS" - - "TypeScript" + - "Astro" + - "Loco.rs" + music_playlist: + - title: "山中来信" + artist: "InitCool Radio" + album: "站点默认歌单" + url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3" + cover_image_url: "https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80" + accent_color: "#2f6b5f" + description: "适合文章阅读时循环播放的轻氛围曲。" + - title: "风吹松声" + artist: "InitCool Radio" + album: "站点默认歌单" + url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3" + cover_image_url: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80" + accent_color: "#8a5b35" + description: "偏木质感的器乐氛围,适合深夜浏览。" + - title: "夜航小记" + artist: "InitCool Radio" + album: "站点默认歌单" + url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3" + cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80" + accent_color: "#375a7f" + description: "节奏更明显一点,适合切换阅读状态。" + ai_enabled: false + paragraph_comments_enabled: true + ai_provider: "newapi" + ai_api_base: "https://91code.jiangnight.com/v1" + ai_api_key: "sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571" + ai_chat_model: "gpt-5.4" + ai_embedding_model: "fastembed / local all-MiniLM-L6-v2" + ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。" + ai_top_k: 4 + ai_chunk_size: 1200 diff --git a/backend/src/initializers/content_sync.rs b/backend/src/initializers/content_sync.rs index 1be52c0..b28d137 100644 --- a/backend/src/initializers/content_sync.rs +++ b/backend/src/initializers/content_sync.rs @@ -56,6 +56,23 @@ fn is_blank(value: &Option) -> bool { value.as_deref().map(str::trim).unwrap_or("").is_empty() } +fn matches_legacy_ai_defaults(settings: &site_settings::Model) -> bool { + let provider = settings.ai_provider.as_deref().map(str::trim); + let api_base = settings.ai_api_base.as_deref().map(str::trim); + let chat_model = settings.ai_chat_model.as_deref().map(str::trim); + + (provider == Some("openai-compatible") + && api_base == Some("https://api.openai.com/v1") + && chat_model == Some("gpt-4.1-mini") + && is_blank(&settings.ai_api_key)) + || (provider == Some("newapi") + && matches!( + api_base, + Some("https://cliproxy.ai.init.cool") | Some("https://cliproxy.ai.init.cool/v1") + ) + && chat_model == Some("gpt-5.4")) +} + async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { let rows = read_fixture_rows(base, "site_settings.yaml"); let Some(seed) = rows.first() else { @@ -73,6 +90,27 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { }) .filter(|items| !items.is_empty()) .map(|items| serde_json::json!(items)); + let music_playlist = seed["music_playlist"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| { + let title = item["title"].as_str()?.trim(); + let url = item["url"].as_str()?.trim(); + if title.is_empty() || url.is_empty() { + None + } else { + Some(serde_json::json!({ + "title": title, + "url": url, + })) + } + }) + .collect::>() + }) + .filter(|items| !items.is_empty()) + .map(serde_json::Value::Array); let existing = site_settings::Entity::find() .order_by_asc(site_settings::Column::Id) @@ -81,6 +119,7 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { if let Some(existing) = existing { let mut model = existing.clone().into_active_model(); + let should_upgrade_legacy_ai_defaults = matches_legacy_ai_defaults(&existing); if is_blank(&existing.site_name) { model.site_name = Set(as_optional_string(&seed["site_name"])); @@ -130,6 +169,46 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { if existing.tech_stack.is_none() { model.tech_stack = Set(tech_stack); } + if existing.music_playlist.is_none() { + model.music_playlist = Set(music_playlist); + } + if existing.ai_enabled.is_none() { + model.ai_enabled = Set(seed["ai_enabled"].as_bool()); + } + if existing.paragraph_comments_enabled.is_none() { + model.paragraph_comments_enabled = + Set(seed["paragraph_comments_enabled"].as_bool().or(Some(true))); + } + if should_upgrade_legacy_ai_defaults { + model.ai_provider = Set(as_optional_string(&seed["ai_provider"])); + model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"])); + model.ai_api_key = Set(as_optional_string(&seed["ai_api_key"])); + model.ai_chat_model = Set(as_optional_string(&seed["ai_chat_model"])); + } + if is_blank(&existing.ai_provider) { + model.ai_provider = Set(as_optional_string(&seed["ai_provider"])); + } + if is_blank(&existing.ai_api_base) { + model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"])); + } + if is_blank(&existing.ai_api_key) { + model.ai_api_key = Set(as_optional_string(&seed["ai_api_key"])); + } + if is_blank(&existing.ai_chat_model) { + model.ai_chat_model = Set(as_optional_string(&seed["ai_chat_model"])); + } + if is_blank(&existing.ai_embedding_model) { + model.ai_embedding_model = Set(as_optional_string(&seed["ai_embedding_model"])); + } + if is_blank(&existing.ai_system_prompt) { + model.ai_system_prompt = Set(as_optional_string(&seed["ai_system_prompt"])); + } + if existing.ai_top_k.is_none() { + model.ai_top_k = Set(seed["ai_top_k"].as_i64().map(|value| value as i32)); + } + if existing.ai_chunk_size.is_none() { + model.ai_chunk_size = Set(seed["ai_chunk_size"].as_i64().map(|value| value as i32)); + } let _ = model.update(&ctx.db).await; return Ok(()); @@ -153,6 +232,19 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> { social_email: Set(as_optional_string(&seed["social_email"])), location: Set(as_optional_string(&seed["location"])), tech_stack: Set(tech_stack), + music_playlist: Set(music_playlist), + ai_enabled: Set(seed["ai_enabled"].as_bool()), + paragraph_comments_enabled: Set(seed["paragraph_comments_enabled"] + .as_bool() + .or(Some(true))), + ai_provider: Set(as_optional_string(&seed["ai_provider"])), + ai_api_base: Set(as_optional_string(&seed["ai_api_base"])), + ai_api_key: Set(as_optional_string(&seed["ai_api_key"])), + ai_chat_model: Set(as_optional_string(&seed["ai_chat_model"])), + ai_embedding_model: Set(as_optional_string(&seed["ai_embedding_model"])), + ai_system_prompt: Set(as_optional_string(&seed["ai_system_prompt"])), + ai_top_k: Set(seed["ai_top_k"].as_i64().map(|value| value as i32)), + ai_chunk_size: Set(seed["ai_chunk_size"].as_i64().map(|value| value as i32)), ..Default::default() }; diff --git a/backend/src/initializers/mod.rs b/backend/src/initializers/mod.rs index a415ed2..a5aedbc 100644 --- a/backend/src/initializers/mod.rs +++ b/backend/src/initializers/mod.rs @@ -1,2 +1 @@ pub mod content_sync; -pub mod view_engine; diff --git a/backend/src/initializers/view_engine.rs b/backend/src/initializers/view_engine.rs deleted file mode 100644 index b6a6855..0000000 --- a/backend/src/initializers/view_engine.rs +++ /dev/null @@ -1,43 +0,0 @@ -use async_trait::async_trait; -use axum::{Extension, Router as AxumRouter}; -use fluent_templates::{ArcLoader, FluentLoader}; -use loco_rs::{ - app::{AppContext, Initializer}, - controller::views::{engines, ViewEngine}, - Error, Result, -}; -use tracing::info; - -const I18N_DIR: &str = "assets/i18n"; -const I18N_SHARED: &str = "assets/i18n/shared.ftl"; -#[allow(clippy::module_name_repetitions)] -pub struct ViewEngineInitializer; - -#[async_trait] -impl Initializer for ViewEngineInitializer { - fn name(&self) -> String { - "view-engine".to_string() - } - - async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result { - let tera_engine = if std::path::Path::new(I18N_DIR).exists() { - let arc = std::sync::Arc::new( - ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US")) - .shared_resources(Some(&[I18N_SHARED.into()])) - .customize(|bundle| bundle.set_use_isolating(false)) - .build() - .map_err(|e| Error::string(&e.to_string()))?, - ); - info!("locales loaded"); - - engines::TeraView::build()?.post_process(move |tera| { - tera.register_function("t", FluentLoader::new(arc.clone())); - Ok(()) - })? - } else { - engines::TeraView::build()? - }; - - Ok(router.layer(Extension(ViewEngine::from(tera_engine)))) - } -} diff --git a/backend/src/mailers/mod.rs b/backend/src/mailers/mod.rs index 0e4a05d..5a8ffd4 100644 --- a/backend/src/mailers/mod.rs +++ b/backend/src/mailers/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod subscription; diff --git a/backend/src/mailers/subscription.rs b/backend/src/mailers/subscription.rs new file mode 100644 index 0000000..180c04a --- /dev/null +++ b/backend/src/mailers/subscription.rs @@ -0,0 +1,77 @@ +#![allow(non_upper_case_globals)] + +use loco_rs::prelude::*; +use serde_json::json; + +static confirm: Dir<'_> = include_dir!("src/mailers/subscription/confirm"); +static notification: Dir<'_> = include_dir!("src/mailers/subscription/notification"); + +pub struct SubscriptionMailer {} +impl Mailer for SubscriptionMailer {} + +impl SubscriptionMailer { + pub async fn send_confirmation( + ctx: &AppContext, + to: &str, + site_name: Option<&str>, + site_url: Option<&str>, + confirm_url: &str, + manage_url: Option<&str>, + ) -> Result<()> { + Self::mail_template( + ctx, + &confirm, + mailer::Args { + to: to.to_string(), + locals: json!({ + "subject": "请确认你的订阅", + "siteName": site_name.unwrap_or("Termi"), + "siteUrl": site_url + .map(ToString::to_string) + .unwrap_or_else(|| ctx.config.server.full_url()), + "confirmUrl": confirm_url, + "manageUrl": manage_url, + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } + + pub async fn send_notification( + ctx: &AppContext, + to: &str, + subject: &str, + headline: &str, + body: &str, + site_name: Option<&str>, + site_url: Option<&str>, + manage_url: Option<&str>, + unsubscribe_url: Option<&str>, + ) -> Result<()> { + Self::mail_template( + ctx, + ¬ification, + mailer::Args { + to: to.to_string(), + locals: json!({ + "subject": subject, + "headline": headline, + "body": body, + "siteName": site_name.unwrap_or("Termi"), + "siteUrl": site_url + .map(ToString::to_string) + .unwrap_or_else(|| ctx.config.server.full_url()), + "manageUrl": manage_url, + "unsubscribeUrl": unsubscribe_url, + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } +} diff --git a/backend/src/mailers/subscription/confirm/html.t b/backend/src/mailers/subscription/confirm/html.t new file mode 100644 index 0000000..4ada08b --- /dev/null +++ b/backend/src/mailers/subscription/confirm/html.t @@ -0,0 +1,25 @@ + + +
+

{{ siteName }}

+

请确认你的订阅

+

为了确认这是你本人提交的邮箱,请点击下面的确认按钮。

+

+ 确认订阅 +

+

+ 如果按钮无法点击,请直接打开:
+ {{ confirmUrl }} +

+ {% if manageUrl %} +

+ 确认完成后,你可以在这里管理偏好:
+ {{ manageUrl }} +

+ {% endif %} +

+ 来自 {{ siteName }} · {{ siteUrl }} +

+
+ + diff --git a/backend/src/mailers/subscription/confirm/subject.t b/backend/src/mailers/subscription/confirm/subject.t new file mode 100644 index 0000000..05766d8 --- /dev/null +++ b/backend/src/mailers/subscription/confirm/subject.t @@ -0,0 +1 @@ +请确认你的订阅 diff --git a/backend/src/mailers/subscription/confirm/text.t b/backend/src/mailers/subscription/confirm/text.t new file mode 100644 index 0000000..4bbcf46 --- /dev/null +++ b/backend/src/mailers/subscription/confirm/text.t @@ -0,0 +1,13 @@ +你好, + +请点击下面的链接确认你的订阅: +{{ confirmUrl }} + +{% if manageUrl %} +确认完成后,你也可以通过这个链接管理偏好: +{{ manageUrl }} +{% endif %} + +-- +{{ siteName }} +{{ siteUrl }} diff --git a/backend/src/mailers/subscription/notification/html.t b/backend/src/mailers/subscription/notification/html.t new file mode 100644 index 0000000..b8a6c98 --- /dev/null +++ b/backend/src/mailers/subscription/notification/html.t @@ -0,0 +1,22 @@ + + +
+

{{ siteName }}

+

{{ headline }}

+
{{ body }}
+ {% if manageUrl or unsubscribeUrl %} +
+ {% if manageUrl %} + 管理订阅 + {% endif %} + {% if unsubscribeUrl %} + 取消订阅 + {% endif %} +
+ {% endif %} +

+ 来自 {{ siteName }} · {{ siteUrl }} +

+
+ + diff --git a/backend/src/mailers/subscription/notification/subject.t b/backend/src/mailers/subscription/notification/subject.t new file mode 100644 index 0000000..7f3280c --- /dev/null +++ b/backend/src/mailers/subscription/notification/subject.t @@ -0,0 +1 @@ +{{ subject }} diff --git a/backend/src/mailers/subscription/notification/text.t b/backend/src/mailers/subscription/notification/text.t new file mode 100644 index 0000000..5dbc70a --- /dev/null +++ b/backend/src/mailers/subscription/notification/text.t @@ -0,0 +1,14 @@ +{{ headline }} + +{{ body }} + +{% if manageUrl %} +管理订阅:{{ manageUrl }} +{% endif %} +{% if unsubscribeUrl %} +取消订阅:{{ unsubscribeUrl }} +{% endif %} + +-- +{{ siteName }} +{{ siteUrl }} diff --git a/backend/src/models/_entities/admin_audit_logs.rs b/backend/src/models/_entities/admin_audit_logs.rs new file mode 100644 index 0000000..fba2515 --- /dev/null +++ b/backend/src/models/_entities/admin_audit_logs.rs @@ -0,0 +1,27 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "admin_audit_logs")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub actor_username: Option, + pub actor_email: Option, + pub actor_source: Option, + pub action: String, + pub target_type: String, + pub target_id: Option, + pub target_label: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub metadata: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/ai_chunks.rs b/backend/src/models/_entities/ai_chunks.rs new file mode 100644 index 0000000..9c0846a --- /dev/null +++ b/backend/src/models/_entities/ai_chunks.rs @@ -0,0 +1,28 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "ai_chunks")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub source_slug: String, + pub source_title: Option, + pub source_path: Option, + pub source_type: String, + pub chunk_index: i32, + #[sea_orm(column_type = "Text")] + pub content: String, + pub content_preview: Option, + pub embedding: Option, + pub word_count: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/comment_blacklist.rs b/backend/src/models/_entities/comment_blacklist.rs new file mode 100644 index 0000000..fe04e97 --- /dev/null +++ b/backend/src/models/_entities/comment_blacklist.rs @@ -0,0 +1,24 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "comment_blacklist")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub matcher_type: String, + pub matcher_value: String, + #[sea_orm(column_type = "Text", nullable)] + pub reason: Option, + pub active: Option, + pub expires_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/comment_persona_analysis_logs.rs b/backend/src/models/_entities/comment_persona_analysis_logs.rs new file mode 100644 index 0000000..510813b --- /dev/null +++ b/backend/src/models/_entities/comment_persona_analysis_logs.rs @@ -0,0 +1,28 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "comment_persona_analysis_logs")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub matcher_type: String, + pub matcher_value: String, + pub from_at: Option, + pub to_at: Option, + pub total_comments: i32, + pub pending_comments: i32, + pub distinct_posts: i32, + #[sea_orm(column_type = "Text")] + pub analysis_text: String, + pub sample_json: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/comments.rs b/backend/src/models/_entities/comments.rs index 6ee8da7..af22cdb 100644 --- a/backend/src/models/_entities/comments.rs +++ b/backend/src/models/_entities/comments.rs @@ -15,9 +15,16 @@ pub struct Model { pub author: Option, pub email: Option, pub avatar: Option, + pub ip_address: Option, + pub user_agent: Option, + pub referer: Option, #[sea_orm(column_type = "Text", nullable)] pub content: Option, + pub scope: String, + pub paragraph_key: Option, + pub paragraph_excerpt: Option, pub reply_to: Option, + pub reply_to_comment_id: Option, pub approved: Option, } diff --git a/backend/src/models/_entities/content_events.rs b/backend/src/models/_entities/content_events.rs new file mode 100644 index 0000000..4c94956 --- /dev/null +++ b/backend/src/models/_entities/content_events.rs @@ -0,0 +1,29 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "content_events")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub event_type: String, + pub path: String, + pub post_slug: Option, + pub session_id: Option, + pub referrer: Option, + #[sea_orm(column_type = "Text", nullable)] + pub user_agent: Option, + pub duration_ms: Option, + pub progress_percent: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub metadata: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/mod.rs b/backend/src/models/_entities/mod.rs index 70bcd16..7456742 100644 --- a/backend/src/models/_entities/mod.rs +++ b/backend/src/models/_entities/mod.rs @@ -1,12 +1,21 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 +pub mod ai_chunks; pub mod prelude; +pub mod admin_audit_logs; pub mod categories; +pub mod comment_blacklist; +pub mod comment_persona_analysis_logs; pub mod comments; +pub mod content_events; pub mod friend_links; +pub mod notification_deliveries; +pub mod post_revisions; pub mod posts; +pub mod query_events; pub mod reviews; pub mod site_settings; +pub mod subscriptions; pub mod tags; pub mod users; diff --git a/backend/src/models/_entities/notification_deliveries.rs b/backend/src/models/_entities/notification_deliveries.rs new file mode 100644 index 0000000..4a04e1f --- /dev/null +++ b/backend/src/models/_entities/notification_deliveries.rs @@ -0,0 +1,32 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "notification_deliveries")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub subscription_id: Option, + pub channel_type: String, + pub target: String, + pub event_type: String, + pub status: String, + pub provider: Option, + #[sea_orm(column_type = "Text", nullable)] + pub response_text: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub payload: Option, + pub attempts_count: i32, + pub next_retry_at: Option, + pub last_attempt_at: Option, + pub delivered_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/post_revisions.rs b/backend/src/models/_entities/post_revisions.rs new file mode 100644 index 0000000..d64a4fa --- /dev/null +++ b/backend/src/models/_entities/post_revisions.rs @@ -0,0 +1,30 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "post_revisions")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub post_slug: String, + pub post_title: Option, + pub operation: String, + #[sea_orm(column_type = "Text", nullable)] + pub revision_reason: Option, + pub actor_username: Option, + pub actor_email: Option, + pub actor_source: Option, + #[sea_orm(column_type = "Text", nullable)] + pub markdown: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub metadata: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/posts.rs b/backend/src/models/_entities/posts.rs index 489f197..b66a3ec 100644 --- a/backend/src/models/_entities/posts.rs +++ b/backend/src/models/_entities/posts.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 +//! `SeaORM` Entity, manually maintained use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -20,7 +20,22 @@ pub struct Model { pub tags: Option, pub post_type: Option, pub image: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub images: Option, pub pinned: Option, + pub status: Option, + pub visibility: Option, + pub publish_at: Option, + pub unpublish_at: Option, + #[sea_orm(column_type = "Text", nullable)] + pub canonical_url: Option, + pub noindex: Option, + #[sea_orm(column_type = "Text", nullable)] + pub og_image: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub redirect_from: Option, + #[sea_orm(column_type = "Text", nullable)] + pub redirect_to: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/backend/src/models/_entities/prelude.rs b/backend/src/models/_entities/prelude.rs index a6dfc59..1feda7a 100644 --- a/backend/src/models/_entities/prelude.rs +++ b/backend/src/models/_entities/prelude.rs @@ -1,10 +1,19 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 +pub use super::ai_chunks::Entity as AiChunks; +pub use super::admin_audit_logs::Entity as AdminAuditLogs; pub use super::categories::Entity as Categories; +pub use super::comment_blacklist::Entity as CommentBlacklist; +pub use super::comment_persona_analysis_logs::Entity as CommentPersonaAnalysisLogs; pub use super::comments::Entity as Comments; +pub use super::content_events::Entity as ContentEvents; pub use super::friend_links::Entity as FriendLinks; +pub use super::notification_deliveries::Entity as NotificationDeliveries; +pub use super::post_revisions::Entity as PostRevisions; pub use super::posts::Entity as Posts; +pub use super::query_events::Entity as QueryEvents; pub use super::reviews::Entity as Reviews; pub use super::site_settings::Entity as SiteSettings; +pub use super::subscriptions::Entity as Subscriptions; pub use super::tags::Entity as Tags; pub use super::users::Entity as Users; diff --git a/backend/src/models/_entities/query_events.rs b/backend/src/models/_entities/query_events.rs new file mode 100644 index 0000000..6628dbe --- /dev/null +++ b/backend/src/models/_entities/query_events.rs @@ -0,0 +1,33 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "query_events")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub event_type: String, + #[sea_orm(column_type = "Text")] + pub query_text: String, + #[sea_orm(column_type = "Text")] + pub normalized_query: String, + pub request_path: Option, + pub referrer: Option, + #[sea_orm(column_type = "Text", nullable)] + pub user_agent: Option, + pub result_count: Option, + pub success: Option, + pub response_mode: Option, + pub provider: Option, + pub chat_model: Option, + pub latency_ms: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/_entities/reviews.rs b/backend/src/models/_entities/reviews.rs index 1f15361..9bf78be 100644 --- a/backend/src/models/_entities/reviews.rs +++ b/backend/src/models/_entities/reviews.rs @@ -14,6 +14,7 @@ pub struct Model { pub description: Option, pub tags: Option, pub cover: Option, + pub link_url: Option, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, } diff --git a/backend/src/models/_entities/site_settings.rs b/backend/src/models/_entities/site_settings.rs index 4795054..8d49ab2 100644 --- a/backend/src/models/_entities/site_settings.rs +++ b/backend/src/models/_entities/site_settings.rs @@ -28,6 +28,45 @@ pub struct Model { pub location: Option, #[sea_orm(column_type = "JsonBinary", nullable)] pub tech_stack: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub music_playlist: Option, + pub ai_enabled: Option, + pub paragraph_comments_enabled: Option, + pub ai_provider: Option, + pub ai_api_base: Option, + #[sea_orm(column_type = "Text", nullable)] + pub ai_api_key: Option, + pub ai_chat_model: Option, + pub ai_image_provider: Option, + pub ai_image_api_base: Option, + #[sea_orm(column_type = "Text", nullable)] + pub ai_image_api_key: Option, + pub ai_image_model: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub ai_providers: Option, + pub ai_active_provider_id: Option, + pub ai_embedding_model: Option, + #[sea_orm(column_type = "Text", nullable)] + pub ai_system_prompt: Option, + pub ai_top_k: Option, + pub ai_chunk_size: Option, + pub ai_last_indexed_at: Option, + pub media_storage_provider: Option, + pub media_r2_account_id: Option, + pub media_r2_bucket: Option, + pub media_r2_public_base_url: Option, + pub media_r2_access_key_id: Option, + #[sea_orm(column_type = "Text", nullable)] + pub media_r2_secret_access_key: Option, + #[sea_orm(column_type = "Text", nullable)] + pub seo_default_og_image: Option, + pub seo_default_twitter_handle: Option, + #[sea_orm(column_type = "Text", nullable)] + pub notification_webhook_url: Option, + pub notification_comment_enabled: Option, + pub notification_friend_link_enabled: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub search_synonyms: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/backend/src/models/_entities/subscriptions.rs b/backend/src/models/_entities/subscriptions.rs new file mode 100644 index 0000000..39a287f --- /dev/null +++ b/backend/src/models/_entities/subscriptions.rs @@ -0,0 +1,36 @@ +//! `SeaORM` Entity, manually maintained + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "subscriptions")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub channel_type: String, + pub target: String, + pub display_name: Option, + pub status: String, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub filters: Option, + #[sea_orm(column_type = "Text", nullable)] + pub secret: Option, + #[sea_orm(column_type = "Text", nullable)] + pub notes: Option, + pub confirm_token: Option, + pub manage_token: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub metadata: Option, + pub verified_at: Option, + pub last_notified_at: Option, + pub failure_count: Option, + pub last_delivery_status: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/ai_chunks.rs b/backend/src/models/ai_chunks.rs new file mode 100644 index 0000000..bdfe305 --- /dev/null +++ b/backend/src/models/ai_chunks.rs @@ -0,0 +1,3 @@ +pub use super::_entities::ai_chunks::{ActiveModel, Entity, Model}; + +pub type AiChunks = Entity; diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index bf149a6..d7659d4 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod _entities; +pub mod ai_chunks; pub mod categories; pub mod comments; pub mod friend_links; diff --git a/backend/src/services/abuse_guard.rs b/backend/src/services/abuse_guard.rs new file mode 100644 index 0000000..7ada02a --- /dev/null +++ b/backend/src/services/abuse_guard.rs @@ -0,0 +1,210 @@ +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, +}; + +use axum::http::{header, HeaderMap, StatusCode}; +use chrono::{DateTime, Duration, Utc}; +use loco_rs::{ + controller::ErrorDetail, + prelude::*, +}; + +const DEFAULT_WINDOW_SECONDS: i64 = 5 * 60; +const DEFAULT_MAX_REQUESTS_PER_WINDOW: u32 = 45; +const DEFAULT_BAN_MINUTES: i64 = 30; +const DEFAULT_BURST_LIMIT: u32 = 8; +const DEFAULT_BURST_WINDOW_SECONDS: i64 = 30; + +const ENV_WINDOW_SECONDS: &str = "TERMI_PUBLIC_RATE_LIMIT_WINDOW_SECONDS"; +const ENV_MAX_REQUESTS_PER_WINDOW: &str = "TERMI_PUBLIC_RATE_LIMIT_MAX"; +const ENV_BAN_MINUTES: &str = "TERMI_PUBLIC_RATE_LIMIT_BAN_MINUTES"; +const ENV_BURST_LIMIT: &str = "TERMI_PUBLIC_RATE_LIMIT_BURST_MAX"; +const ENV_BURST_WINDOW_SECONDS: &str = "TERMI_PUBLIC_RATE_LIMIT_BURST_WINDOW_SECONDS"; + +#[derive(Clone, Debug)] +struct AbuseGuardConfig { + window_seconds: i64, + max_requests_per_window: u32, + ban_minutes: i64, + burst_limit: u32, + burst_window_seconds: i64, +} + +#[derive(Clone, Debug)] +struct AbuseGuardEntry { + window_started_at: DateTime, + request_count: u32, + burst_window_started_at: DateTime, + burst_count: u32, + banned_until: Option>, + last_reason: Option, +} + +fn parse_env_i64(name: &str, fallback: i64, min: i64, max: i64) -> i64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .map(|value| value.clamp(min, max)) + .unwrap_or(fallback) +} + +fn parse_env_u32(name: &str, fallback: u32, min: u32, max: u32) -> u32 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .map(|value| value.clamp(min, max)) + .unwrap_or(fallback) +} + +fn load_config() -> AbuseGuardConfig { + AbuseGuardConfig { + window_seconds: parse_env_i64(ENV_WINDOW_SECONDS, DEFAULT_WINDOW_SECONDS, 10, 24 * 60 * 60), + max_requests_per_window: parse_env_u32( + ENV_MAX_REQUESTS_PER_WINDOW, + DEFAULT_MAX_REQUESTS_PER_WINDOW, + 1, + 50_000, + ), + ban_minutes: parse_env_i64(ENV_BAN_MINUTES, DEFAULT_BAN_MINUTES, 1, 7 * 24 * 60), + burst_limit: parse_env_u32(ENV_BURST_LIMIT, DEFAULT_BURST_LIMIT, 1, 1_000), + burst_window_seconds: parse_env_i64( + ENV_BURST_WINDOW_SECONDS, + DEFAULT_BURST_WINDOW_SECONDS, + 5, + 60 * 60, + ), + } +} + +fn normalize_token(value: Option<&str>, max_chars: usize) -> Option { + value.and_then(|item| { + let trimmed = item.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.chars().take(max_chars).collect::()) + } + }) +} + +fn normalize_ip(value: Option<&str>) -> Option { + normalize_token(value, 96) +} + +pub fn header_value<'a>(headers: &'a HeaderMap, key: header::HeaderName) -> Option<&'a str> { + headers.get(key).and_then(|value| value.to_str().ok()) +} + +fn first_forwarded_ip(value: &str) -> Option<&str> { + value + .split(',') + .map(str::trim) + .find(|item| !item.is_empty()) +} + +pub fn detect_client_ip(headers: &HeaderMap) -> Option { + let forwarded = header_value(headers, header::HeaderName::from_static("x-forwarded-for")) + .and_then(first_forwarded_ip); + let real_ip = header_value(headers, header::HeaderName::from_static("x-real-ip")); + let cf_connecting_ip = + header_value(headers, header::HeaderName::from_static("cf-connecting-ip")); + let true_client_ip = header_value(headers, header::HeaderName::from_static("true-client-ip")); + + normalize_ip( + forwarded + .or(real_ip) + .or(cf_connecting_ip) + .or(true_client_ip), + ) +} + +fn abuse_store() -> &'static Mutex> { + static STORE: OnceLock>> = OnceLock::new(); + STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn make_key(scope: &str, client_ip: Option<&str>, fingerprint: Option<&str>) -> String { + let normalized_scope = scope.trim().to_ascii_lowercase(); + let normalized_ip = normalize_ip(client_ip).unwrap_or_else(|| "unknown".to_string()); + let normalized_fingerprint = normalize_token(fingerprint, 160).unwrap_or_default(); + if normalized_fingerprint.is_empty() { + format!("{normalized_scope}:{normalized_ip}") + } else { + format!("{normalized_scope}:{normalized_ip}:{normalized_fingerprint}") + } +} + +fn too_many_requests(message: impl Into) -> Error { + let message = message.into(); + Error::CustomError( + StatusCode::TOO_MANY_REQUESTS, + ErrorDetail::new("rate_limited".to_string(), message), + ) +} + +pub fn enforce_public_scope( + scope: &str, + client_ip: Option<&str>, + fingerprint: Option<&str>, +) -> Result<()> { + let config = load_config(); + let key = make_key(scope, client_ip, fingerprint); + let now = Utc::now(); + let mut store = abuse_store() + .lock() + .map_err(|_| Error::InternalServerError)?; + + store.retain(|_, entry| { + entry + .banned_until + .map(|until| until > now - Duration::days(1)) + .unwrap_or_else(|| entry.window_started_at > now - Duration::days(1)) + }); + + let entry = store.entry(key).or_insert_with(|| AbuseGuardEntry { + window_started_at: now, + request_count: 0, + burst_window_started_at: now, + burst_count: 0, + banned_until: None, + last_reason: None, + }); + + if let Some(banned_until) = entry.banned_until { + if banned_until > now { + let retry_after = (banned_until - now).num_minutes().max(1); + return Err(too_many_requests(format!( + "请求过于频繁,请在 {retry_after} 分钟后重试" + ))); + } + entry.banned_until = None; + } + + if entry.window_started_at + Duration::seconds(config.window_seconds) <= now { + entry.window_started_at = now; + entry.request_count = 0; + } + + if entry.burst_window_started_at + Duration::seconds(config.burst_window_seconds) <= now { + entry.burst_window_started_at = now; + entry.burst_count = 0; + } + + entry.request_count += 1; + entry.burst_count += 1; + + if entry.burst_count > config.burst_limit { + entry.banned_until = Some(now + Duration::minutes(config.ban_minutes)); + entry.last_reason = Some("burst_limit".to_string()); + return Err(too_many_requests("短时间请求过多,已临时封禁,请稍后再试")); + } + + if entry.request_count > config.max_requests_per_window { + entry.banned_until = Some(now + Duration::minutes(config.ban_minutes)); + entry.last_reason = Some("window_limit".to_string()); + return Err(too_many_requests("请求过于频繁,已临时封禁,请稍后再试")); + } + + Ok(()) +} diff --git a/backend/src/services/admin_audit.rs b/backend/src/services/admin_audit.rs new file mode 100644 index 0000000..3f4835d --- /dev/null +++ b/backend/src/services/admin_audit.rs @@ -0,0 +1,33 @@ +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, Set}; + +use crate::{ + controllers::admin::AdminIdentity, + models::_entities::admin_audit_logs, +}; + +pub async fn log_event( + ctx: &AppContext, + actor: Option<&AdminIdentity>, + action: &str, + target_type: &str, + target_id: Option, + target_label: Option, + metadata: Option, +) -> Result<()> { + admin_audit_logs::ActiveModel { + actor_username: Set(actor.map(|item| item.username.clone())), + actor_email: Set(actor.and_then(|item| item.email.clone())), + actor_source: Set(actor.map(|item| item.source.clone())), + action: Set(action.to_string()), + target_type: Set(target_type.to_string()), + target_id: Set(target_id), + target_label: Set(target_label), + metadata: Set(metadata), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + Ok(()) +} diff --git a/backend/src/services/ai.rs b/backend/src/services/ai.rs new file mode 100644 index 0000000..22aa7f5 --- /dev/null +++ b/backend/src/services/ai.rs @@ -0,0 +1,2774 @@ +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use chrono::{DateTime, Utc}; +use fastembed::{ + InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel, +}; +use loco_rs::prelude::*; +use reqwest::{header::CONTENT_TYPE, multipart, Client, Url}; +use sea_orm::{ + ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel, + PaginatorTrait, QueryOrder, Set, Statement, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use uuid::Uuid; + +use crate::{ + controllers::site_settings as site_settings_controller, + models::_entities::{ai_chunks, site_settings}, + services::{content, storage}, +}; + +const DEFAULT_AI_PROVIDER: &str = "openai"; +const DEFAULT_AI_API_BASE: &str = "https://91code.jiangnight.com/v1"; +const DEFAULT_AI_API_KEY: &str = + "sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571"; +const DEFAULT_CHAT_MODEL: &str = "gpt-5.4"; +const DEFAULT_REASONING_EFFORT: &str = "medium"; +const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true; +const DEFAULT_IMAGE_MODEL: &str = "gpt-image-1"; +const DEFAULT_CLOUDFLARE_CHAT_MODEL: &str = "@cf/meta/llama-3.1-8b-instruct"; +const DEFAULT_CLOUDFLARE_IMAGE_MODEL: &str = "@cf/black-forest-labs/flux-2-klein-4b"; +const DEFAULT_TOP_K: usize = 4; +const DEFAULT_CHUNK_SIZE: usize = 1200; +const DEFAULT_SYSTEM_PROMPT: &str = + "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。"; +const EMBEDDING_BATCH_SIZE: usize = 32; +const EMBEDDING_DIMENSION: usize = 384; +const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2"; +const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2"; +const LOCAL_EMBEDDING_BASE_URL: &str = + "https://huggingface.co/Qdrant/all-MiniLM-L6-v2-onnx/resolve/main"; +const LOCAL_EMBEDDING_FILES: [&str; 5] = [ + "model.onnx", + "tokenizer.json", + "config.json", + "special_tokens_map.json", + "tokenizer_config.json", +]; + +static TEXT_EMBEDDING_MODEL: OnceLock> = OnceLock::new(); + +#[derive(Clone, Debug)] +struct AiImageRuntimeSettings { + provider: String, + api_base: Option, + api_key: Option, + image_model: String, +} + +#[derive(Clone, Debug)] +struct AiRuntimeSettings { + raw: site_settings::Model, + provider: String, + api_base: Option, + api_key: Option, + chat_model: String, + image: AiImageRuntimeSettings, + system_prompt: String, + top_k: usize, + chunk_size: usize, +} + +#[derive(Clone, Debug)] +struct ChunkDraft { + source_slug: String, + source_title: Option, + source_path: Option, + source_type: String, + chunk_index: i32, + content: String, + content_preview: Option, + word_count: Option, +} + +#[derive(Clone, Debug)] +struct ScoredChunk { + score: f64, + row: ai_chunks::Model, +} + +#[derive(Clone, Debug, FromQueryResult)] +struct SimilarChunkRow { + source_slug: String, + source_title: Option, + source_type: String, + chunk_index: i32, + content: String, + content_preview: Option, + word_count: Option, + score: f64, +} + +#[derive(Clone, Copy, Debug)] +enum EmbeddingKind { + Passage, + Query, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AiSource { + pub slug: String, + pub href: String, + pub title: String, + pub excerpt: String, + pub score: f64, + pub chunk_index: i32, +} + +#[derive(Clone, Debug, Serialize)] +pub struct GeneratedPostMetadata { + pub title: String, + pub description: String, + pub category: String, + pub tags: Vec, + pub slug: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PolishedPostMarkdown { + pub polished_markdown: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PolishedReviewDescription { + pub polished_description: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct GeneratedPostCoverImage { + pub image_url: String, + pub prompt: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AiProviderConnectivityResult { + pub provider: String, + pub endpoint: String, + pub chat_model: String, + pub reply_preview: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AiImageProviderConnectivityResult { + pub provider: String, + pub endpoint: String, + pub image_model: String, + pub result_preview: String, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct GeneratedPostMetadataDraft { + title: Option, + description: Option, + category: Option, + tags: Option>, + slug: Option, +} + +#[derive(Clone, Debug)] +pub struct AiAnswer { + pub answer: String, + pub sources: Vec, + pub indexed_chunks: usize, + pub last_indexed_at: Option>, +} + +#[derive(Clone, Debug)] +pub(crate) struct AiProviderRequest { + pub(crate) provider: String, + pub(crate) api_base: String, + pub(crate) api_key: String, + pub(crate) chat_model: String, + pub(crate) system_prompt: String, + pub(crate) prompt: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct PreparedAiAnswer { + pub(crate) question: String, + pub(crate) provider_request: Option, + pub(crate) immediate_answer: Option, + pub(crate) sources: Vec, + pub(crate) indexed_chunks: usize, + pub(crate) last_indexed_at: Option>, +} + +#[derive(Clone, Debug)] +pub struct AiIndexSummary { + pub indexed_chunks: usize, + pub last_indexed_at: Option>, +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalize_newlines(input: &str) -> String { + input.replace("\r\n", "\n") +} + +fn preview_text(content: &str, limit: usize) -> Option { + let flattened = content + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string(); + + if flattened.is_empty() { + return None; + } + + let preview = flattened.chars().take(limit).collect::(); + Some(preview) +} + +fn json_string_array(value: &Option) -> Vec { + value + .as_ref() + .and_then(|raw| serde_json::from_value::>(raw.clone()).ok()) + .unwrap_or_default() + .into_iter() + .filter_map(|item| trim_to_option(Some(item))) + .collect() +} + +fn build_endpoint(api_base: &str, path: &str) -> String { + format!( + "{}/{}", + api_base.trim_end_matches('/'), + path.trim_start_matches('/') + ) +} + +fn provider_uses_cloudflare(provider: &str) -> bool { + provider.eq_ignore_ascii_case("cloudflare") + || provider.eq_ignore_ascii_case("cloudflare-workers-ai") + || provider.eq_ignore_ascii_case("workers-ai") +} + +fn provider_uses_openai_api_prefix(provider: &str) -> bool { + provider_uses_responses(provider) || provider.eq_ignore_ascii_case("openai-compatible") +} + +fn normalize_cloudflare_api_base(api_base: &str) -> String { + let trimmed = api_base.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return String::new(); + } + + if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") { + return format!( + "https://api.cloudflare.com/client/v4/accounts/{}", + trimmed.trim_matches('/'), + ); + } + + let Ok(mut parsed) = Url::parse(trimmed) else { + return trimmed.to_string(); + }; + + let segments = parsed + .path_segments() + .map(|items| items.collect::>()) + .unwrap_or_default(); + + if let Some(account_index) = segments.iter().position(|segment| *segment == "accounts") { + if let Some(account_id) = segments.get(account_index + 1) { + parsed.set_path(&format!("/client/v4/accounts/{account_id}")); + } + } + + parsed.to_string().trim_end_matches('/').to_string() +} + +fn normalize_provider_api_base(provider: &str, api_base: &str) -> String { + let trimmed = api_base.trim(); + if trimmed.is_empty() { + return String::new(); + } + + if provider_uses_cloudflare(provider) { + return normalize_cloudflare_api_base(trimmed); + } + + if !provider_uses_openai_api_prefix(provider) { + return trimmed.trim_end_matches('/').to_string(); + } + + let Ok(mut parsed) = Url::parse(trimmed) else { + return trimmed.trim_end_matches('/').to_string(); + }; + + if parsed.path().trim_end_matches('/').is_empty() { + parsed.set_path("/v1"); + } + + parsed.to_string().trim_end_matches('/').to_string() +} + +fn local_embedding_dir() -> PathBuf { + PathBuf::from(LOCAL_EMBEDDING_CACHE_DIR) +} + +fn download_embedding_file( + client: &reqwest::blocking::Client, + directory: &Path, + file_name: &str, +) -> Result<()> { + let target_path = directory.join(file_name); + if target_path.exists() { + return Ok(()); + } + + let url = format!("{LOCAL_EMBEDDING_BASE_URL}/{file_name}"); + let bytes = client + .get(url) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|error| Error::BadRequest(format!("下载本地 embedding 文件失败: {error}")))? + .bytes() + .map_err(|error| Error::BadRequest(format!("读取本地 embedding 文件失败: {error}")))?; + + fs::write(&target_path, &bytes) + .map_err(|error| Error::BadRequest(format!("写入本地 embedding 文件失败: {error}")))?; + + Ok(()) +} + +fn ensure_local_embedding_files() -> Result { + let directory = local_embedding_dir(); + fs::create_dir_all(&directory) + .map_err(|error| Error::BadRequest(format!("创建本地 embedding 目录失败: {error}")))?; + + let client = reqwest::blocking::Client::builder() + .build() + .map_err(|error| { + Error::BadRequest(format!("创建本地 embedding 下载客户端失败: {error}")) + })?; + + for file_name in LOCAL_EMBEDDING_FILES { + download_embedding_file(&client, &directory, file_name)?; + } + + Ok(directory) +} + +fn load_local_embedding_model() -> Result { + let directory = ensure_local_embedding_files()?; + let tokenizer_files = TokenizerFiles { + tokenizer_file: fs::read(directory.join("tokenizer.json")) + .map_err(|error| Error::BadRequest(format!("读取 tokenizer.json 失败: {error}")))?, + config_file: fs::read(directory.join("config.json")) + .map_err(|error| Error::BadRequest(format!("读取 config.json 失败: {error}")))?, + special_tokens_map_file: fs::read(directory.join("special_tokens_map.json")).map_err( + |error| Error::BadRequest(format!("读取 special_tokens_map.json 失败: {error}")), + )?, + tokenizer_config_file: fs::read(directory.join("tokenizer_config.json")).map_err( + |error| Error::BadRequest(format!("读取 tokenizer_config.json 失败: {error}")), + )?, + }; + + let model = UserDefinedEmbeddingModel::new( + fs::read(directory.join("model.onnx")) + .map_err(|error| Error::BadRequest(format!("读取 model.onnx 失败: {error}")))?, + tokenizer_files, + ) + .with_pooling(Pooling::Mean); + + TextEmbedding::try_new_from_user_defined(model, InitOptionsUserDefined::default()) + .map_err(|error| Error::BadRequest(format!("本地 embedding 模型初始化失败: {error}"))) +} + +fn local_embedding_engine() -> Result<&'static Mutex> { + if let Some(model) = TEXT_EMBEDDING_MODEL.get() { + return Ok(model); + } + + let model = load_local_embedding_model()?; + + let _ = TEXT_EMBEDDING_MODEL.set(Mutex::new(model)); + + TEXT_EMBEDDING_MODEL + .get() + .ok_or_else(|| Error::BadRequest("本地 embedding 模型未能成功缓存".to_string())) +} + +fn vector_literal(embedding: &[f64]) -> Result { + if embedding.len() != EMBEDDING_DIMENSION { + return Err(Error::BadRequest(format!( + "embedding 维度异常,期望 {EMBEDDING_DIMENSION},实际 {}", + embedding.len() + ))); + } + + Ok(format!( + "[{}]", + embedding + .iter() + .map(|value| value.to_string()) + .collect::>() + .join(",") + )) +} + +fn prepare_embedding_text(kind: EmbeddingKind, text: &str) -> String { + match kind { + EmbeddingKind::Passage | EmbeddingKind::Query => text.trim().to_string(), + } +} + +fn split_long_text(text: &str, chunk_size: usize) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + + for line in text.lines() { + let candidate = if current.is_empty() { + line.to_string() + } else { + format!("{current}\n{line}") + }; + + if candidate.chars().count() > chunk_size && !current.is_empty() { + parts.push(current.trim().to_string()); + current = line.to_string(); + } else { + current = candidate; + } + } + + if !current.trim().is_empty() { + parts.push(current.trim().to_string()); + } + + parts +} + +fn build_source_chunks( + source_slug: String, + source_title: Option, + source_path: Option, + source_type: &str, + source_text: String, + chunk_size: usize, +) -> Vec { + let mut chunks = Vec::new(); + let paragraphs = source_text + .split("\n\n") + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>(); + + let mut buffer = String::new(); + let mut chunk_index = 0_i32; + + for paragraph in paragraphs { + if paragraph.chars().count() > chunk_size { + if !buffer.trim().is_empty() { + chunks.push(ChunkDraft { + source_slug: source_slug.clone(), + source_title: source_title.clone(), + source_path: source_path.clone(), + source_type: source_type.to_string(), + chunk_index, + content: buffer.trim().to_string(), + content_preview: preview_text(&buffer, 180), + word_count: Some(buffer.split_whitespace().count() as i32), + }); + chunk_index += 1; + buffer.clear(); + } + + for part in split_long_text(paragraph, chunk_size) { + if part.trim().is_empty() { + continue; + } + + chunks.push(ChunkDraft { + source_slug: source_slug.clone(), + source_title: source_title.clone(), + source_path: source_path.clone(), + source_type: source_type.to_string(), + chunk_index, + content_preview: preview_text(&part, 180), + word_count: Some(part.split_whitespace().count() as i32), + content: part, + }); + chunk_index += 1; + } + continue; + } + + let candidate = if buffer.is_empty() { + paragraph.to_string() + } else { + format!("{buffer}\n\n{paragraph}") + }; + + if candidate.chars().count() > chunk_size && !buffer.trim().is_empty() { + chunks.push(ChunkDraft { + source_slug: source_slug.clone(), + source_title: source_title.clone(), + source_path: source_path.clone(), + source_type: source_type.to_string(), + chunk_index, + content_preview: preview_text(&buffer, 180), + word_count: Some(buffer.split_whitespace().count() as i32), + content: buffer.trim().to_string(), + }); + chunk_index += 1; + buffer = paragraph.to_string(); + } else { + buffer = candidate; + } + } + + if !buffer.trim().is_empty() { + chunks.push(ChunkDraft { + source_slug, + source_title, + source_path, + source_type: source_type.to_string(), + chunk_index, + content_preview: preview_text(&buffer, 180), + word_count: Some(buffer.split_whitespace().count() as i32), + content: buffer.trim().to_string(), + }); + } + + chunks +} + +fn build_chunks(posts: &[content::MarkdownPost], chunk_size: usize) -> Vec { + let mut chunks = Vec::new(); + let now = chrono::Utc::now().fixed_offset(); + + for post in posts.iter().filter(|post| { + content::effective_post_state( + &post.status, + post.publish_at + .clone() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(&value).ok()), + post.unpublish_at + .clone() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(&value).ok()), + now, + ) == content::POST_STATUS_PUBLISHED + && content::normalize_post_visibility(Some(&post.visibility)) + != content::POST_VISIBILITY_PRIVATE + }) { + let mut sections = Vec::new(); + sections.push(format!("# {}", post.title)); + if let Some(description) = post + .description + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + sections.push(description.trim().to_string()); + } + sections.push(post.content.trim().to_string()); + + let source_text = sections + .into_iter() + .filter(|item| !item.trim().is_empty()) + .collect::>() + .join("\n\n"); + + chunks.extend(build_source_chunks( + post.slug.clone(), + Some(post.title.clone()), + Some(post.file_path.clone()), + "post", + source_text, + chunk_size, + )); + } + + chunks +} + +fn build_profile_chunks(settings: &site_settings::Model, chunk_size: usize) -> Vec { + let owner_name = + trim_to_option(settings.owner_name.clone()).unwrap_or_else(|| "InitCool".to_string()); + let owner_title = trim_to_option(settings.owner_title.clone()); + let owner_bio = trim_to_option(settings.owner_bio.clone()); + let owner_avatar = trim_to_option(settings.owner_avatar_url.clone()); + let github = trim_to_option(settings.social_github.clone()); + let email = trim_to_option(settings.social_email.clone()); + let site_url = trim_to_option(settings.site_url.clone()); + let location = trim_to_option(settings.location.clone()); + let tech_stack = json_string_array(&settings.tech_stack); + + let mut sections = vec![format!("# 关于作者 {owner_name}")]; + + if let Some(title) = owner_title.as_deref() { + sections.push(format!("身份: {title}")); + } + if let Some(bio) = owner_bio.as_deref() { + sections.push(format!("简介: {bio}")); + } + if let Some(location) = location.as_deref() { + sections.push(format!("位置: {location}")); + } + if !tech_stack.is_empty() { + sections.push(format!("技术栈: {}", tech_stack.join(" / "))); + } + if let Some(github) = github.as_deref() { + sections.push(format!("GitHub: {github}")); + } + if let Some(site_url) = site_url.as_deref() { + sections.push(format!("网站: {site_url}")); + } + if let Some(email) = email.as_deref() { + sections.push(format!("邮箱: {email}")); + } + if let Some(owner_avatar) = owner_avatar.as_deref() { + sections.push(format!("头像: {owner_avatar}")); + } + + let profile_text = sections + .into_iter() + .filter(|item| !item.trim().is_empty()) + .collect::>() + .join("\n\n"); + + build_source_chunks( + "about".to_string(), + Some(format!("关于作者 {owner_name}")), + Some("site_settings".to_string()), + "profile", + profile_text, + chunk_size, + ) +} + +fn parse_provider_sse_body(body: &str) -> Option { + let normalized = normalize_newlines(body); + let mut latest_payload = None; + let mut latest_response = None; + + for event in normalized.split("\n\n") { + let data = event + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim_start) + .collect::>() + .join("\n"); + + if data.is_empty() || data == "[DONE]" { + continue; + } + + let Ok(parsed) = serde_json::from_str::(&data) else { + continue; + }; + + if let Some(response) = parsed.get("response") { + latest_response = Some(response.clone()); + } + + latest_payload = Some(parsed); + } + + latest_response.or(latest_payload) +} + +fn parse_json_body(body: &str) -> Result { + serde_json::from_str(body) + .or_else(|_| { + parse_provider_sse_body(body).ok_or_else(|| { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "provider returned neither JSON nor SSE JSON payload", + )) + }) + }) + .map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}"))) +} + +async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result { + let response = client + .post(url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .json(&payload) + .send() + .await + .map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?; + + if !status.is_success() { + return Err(Error::BadRequest(format!( + "AI provider returned {status}: {body}" + ))); + } + + parse_json_body(&body) +} + +async fn request_multipart_json( + client: &Client, + url: &str, + api_key: &str, + form: multipart::Form, +) -> Result { + let response = client + .post(url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .multipart(form) + .send() + .await + .map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?; + + if !status.is_success() { + return Err(Error::BadRequest(format!( + "AI provider returned {status}: {body}" + ))); + } + + parse_json_body(&body) +} + +fn provider_uses_responses(provider: &str) -> bool { + provider.eq_ignore_ascii_case("newapi") + || provider.eq_ignore_ascii_case("openai") + || provider.eq_ignore_ascii_case("anthropic") + || provider.eq_ignore_ascii_case("gemini") +} + +fn default_image_model_for_provider_name(provider: &str) -> &'static str { + if provider_uses_cloudflare(provider) { + DEFAULT_CLOUDFLARE_IMAGE_MODEL + } else { + DEFAULT_IMAGE_MODEL + } +} + +pub fn default_image_model_for_provider(provider: &str) -> &'static str { + default_image_model_for_provider_name(provider) +} + +async fn embed_texts_locally(inputs: Vec, kind: EmbeddingKind) -> Result>> { + tokio::task::spawn_blocking(move || { + let model = local_embedding_engine()?; + let prepared = inputs + .iter() + .map(|item| prepare_embedding_text(kind, item)) + .collect::>(); + + let mut guard = model.lock().map_err(|_| { + Error::BadRequest("本地 embedding 模型当前不可用,请稍后重试".to_string()) + })?; + + let embeddings = guard + .embed(prepared, Some(EMBEDDING_BATCH_SIZE)) + .map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?; + + Ok(embeddings + .into_iter() + .map(|embedding| embedding.into_iter().map(f64::from).collect::>()) + .collect::>()) + }) + .await + .map_err(|error| Error::BadRequest(format!("本地 embedding 任务执行失败: {error}")))? +} + +fn extract_message_content(value: &Value) -> Option { + if let Some(content) = value + .get("message") + .and_then(|message| message.get("content")) + { + if let Some(text) = content.as_str() { + return Some(text.trim().to_string()); + } + + if let Some(parts) = content.as_array() { + let merged = parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(Value::as_str) + .or_else(|| part.get("output_text").and_then(Value::as_str)) + }) + .collect::>() + .join("\n"); + + if !merged.trim().is_empty() { + return Some(merged.trim().to_string()); + } + } + } + + if let Some(content) = value + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + { + if let Some(text) = content.as_str() { + return Some(text.trim().to_string()); + } + + if let Some(parts) = content.as_array() { + let merged = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + + if !merged.trim().is_empty() { + return Some(merged.trim().to_string()); + } + } + } + + None +} + +fn merge_text_segments(parts: Vec) -> Option { + let merged = parts + .into_iter() + .filter_map(|part| { + let trimmed = part.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) + .collect::>() + .join("\n"); + + if merged.trim().is_empty() { + None + } else { + Some(merged) + } +} + +fn extract_text_from_content_items(items: &[Value]) -> Option { + let mut segments = Vec::new(); + + for item in items { + if let Some(text) = item.get("text").and_then(Value::as_str) { + segments.push(text.to_string()); + continue; + } + + if let Some(text) = item + .get("output_text") + .and_then(|output_text| output_text.get("text")) + .and_then(Value::as_str) + { + segments.push(text.to_string()); + continue; + } + + if let Some(text) = item.get("output_text").and_then(Value::as_str) { + segments.push(text.to_string()); + } + } + + merge_text_segments(segments) +} + +fn title_from_markdown(markdown: &str) -> Option { + markdown.lines().find_map(|line| { + line.trim() + .strip_prefix("# ") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) +} + +fn excerpt_from_markdown(markdown: &str, limit: usize) -> Option { + let mut in_code_block = false; + + for line in markdown.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("```") { + in_code_block = !in_code_block; + continue; + } + + if in_code_block || trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let excerpt = trimmed.chars().take(limit).collect::(); + if !excerpt.is_empty() { + return Some(excerpt); + } + } + + None +} + +fn strip_markdown_frontmatter(markdown: &str) -> String { + let normalized = markdown.replace("\r\n", "\n"); + if !normalized.starts_with("---\n") { + return normalized; + } + + let Some(end_index) = normalized[4..].find("\n---\n") else { + return normalized; + }; + + normalized[(end_index + 9)..].to_string() +} + +fn metadata_slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + + for ch in value.trim().chars() { + if ch.is_alphanumeric() { + for lower in ch.to_lowercase() { + slug.push(lower); + } + last_was_dash = false; + } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + + slug.trim_matches('-').to_string() +} + +fn take_json_block(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.starts_with('{') && trimmed.ends_with('}') { + return Some(trimmed.to_string()); + } + + for marker in ["```json", "```JSON", "```"] { + if let Some(start) = trimmed.find(marker) { + let rest = &trimmed[(start + marker.len())..]; + if let Some(end) = rest.find("```") { + let candidate = rest[..end].trim(); + if candidate.starts_with('{') && candidate.ends_with('}') { + return Some(candidate.to_string()); + } + } + } + } + + let start = trimmed.find('{')?; + let end = trimmed.rfind('}')?; + (start < end).then(|| trimmed[start..=end].to_string()) +} + +fn normalize_generated_metadata( + markdown: &str, + draft: GeneratedPostMetadataDraft, +) -> GeneratedPostMetadata { + let fallback_title = title_from_markdown(markdown).unwrap_or_else(|| "未命名文章".to_string()); + let title = trim_to_option(draft.title).unwrap_or_else(|| fallback_title.clone()); + let description = trim_to_option(draft.description) + .or_else(|| excerpt_from_markdown(markdown, 140)) + .unwrap_or_else(|| format!("关于《{title}》的文章摘要。")); + let category = trim_to_option(draft.category).unwrap_or_else(|| "未分类".to_string()); + + let mut seen = std::collections::HashSet::new(); + let tags = draft + .tags + .unwrap_or_default() + .into_iter() + .filter_map(|tag| trim_to_option(Some(tag))) + .filter(|tag| { + let key = tag.to_lowercase(); + seen.insert(key) + }) + .take(6) + .collect::>(); + + let slug_source = trim_to_option(draft.slug) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| title.clone()); + let slug = metadata_slugify(&slug_source); + + GeneratedPostMetadata { + title, + description, + category, + tags, + slug: if slug.is_empty() { + metadata_slugify(&fallback_title) + } else { + slug + }, + } +} + +fn build_post_metadata_prompt(markdown: &str) -> String { + let content = strip_markdown_frontmatter(markdown) + .chars() + .take(6000) + .collect::(); + + format!( + "请根据下面的 Markdown 文章内容,生成适合博客后台直接回填的元数据。\n\ +要求:\n\ +1. 使用中文理解文章,但 slug 必须是适合 URL 的短横线形式。\n\ +2. title 要自然,不要保留“未命名文章”之类的占位词。\n\ +3. description 控制在 40 到 120 个中文字符之间,像站点摘要,不要分点。\n\ +4. category 只输出 1 个分类名称。\n\ +5. tags 输出 3 到 6 个标签,尽量具体,不要和 category 完全重复。\n\ +6. 只返回 JSON,不要解释,不要代码块。\n\ +JSON 结构:\n\ +{{\n\ + \"title\": \"\",\n\ + \"description\": \"\",\n\ + \"category\": \"\",\n\ + \"tags\": [\"\", \"\"],\n\ + \"slug\": \"\"\n\ +}}\n\n\ +文章内容:\n{content}" + ) +} + +fn infer_category_and_tags(markdown: &str) -> (String, Vec) { + let normalized = strip_markdown_frontmatter(markdown).to_lowercase(); + let candidates = [ + ("canokey", "Linux", "CanoKey"), + ("ffmpeg", "ffmpeg", "ffmpeg"), + ("grpc", "Go", "gRPC"), + ("protobuf", "Go", "Protobuf"), + ("protoc", "Go", "Go"), + ("go ", "Go", "Go"), + ("golang", "Go", "Go"), + ("rust", "Rust", "Rust"), + ("serde", "Rust", "Serde"), + ("sqlx", "Rust", "Sqlx"), + ("dll", "Rust", "Dll"), + ("mysql", "Database", "Mysql"), + ("redis", "Database", "Redis"), + ("sql", "Database", "Sql"), + ("linux", "Linux", "Linux"), + ("shell", "Linux", "Shell"), + ("tmux", "Linux", "Tmux"), + ("dhcp", "Linux", "DHCP"), + ("glibc", "Linux", "GLIBC"), + ("hugo", "Go", "Hugo"), + ("xml", "Go", "Xml"), + ("arm", "Go", "Arm"), + ]; + + let mut category = None; + let mut tags = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for (needle, matched_category, tag) in candidates { + if !normalized.contains(needle) { + continue; + } + + if category.is_none() { + category = Some(matched_category.to_string()); + } + + if seen.insert(tag.to_lowercase()) { + tags.push(tag.to_string()); + } + } + + if tags.is_empty() { + tags.push("技术笔记".to_string()); + } + + (category.unwrap_or_else(|| "开发".to_string()), tags) +} + +fn fallback_generated_metadata(markdown: &str) -> GeneratedPostMetadata { + let fallback_title = title_from_markdown(markdown).unwrap_or_else(|| "未命名文章".to_string()); + let (category, tags) = infer_category_and_tags(markdown); + + normalize_generated_metadata( + markdown, + GeneratedPostMetadataDraft { + title: Some(fallback_title.clone()), + description: excerpt_from_markdown(markdown, 96) + .or_else(|| Some(format!("关于《{fallback_title}》的技术实践记录。"))), + category: Some(category), + tags: Some(tags), + slug: Some(metadata_slugify(&fallback_title)), + }, + ) +} + +fn build_polish_markdown_prompt(markdown: &str) -> String { + let content = normalize_newlines(markdown) + .chars() + .take(12000) + .collect::(); + + format!( + "请润色下面这篇博客 Markdown 文档,输出一份可直接保存的新版本。\n\ +要求:\n\ +1. 直接返回完整 Markdown 文档,不要解释,不要代码块包裹。\n\ +2. 如果文档包含 frontmatter,请一起优化 title、description、category、tags、slug;保留图片、发布状态等合理字段。\n\ +3. 正文要更顺、更准确、更适合发布,但不要改掉核心事实。\n\ +4. 保持 Markdown 结构清晰,标题层级合理,列表和代码块不要损坏。\n\ +5. 使用中文润色,技术名词保持正确。\n\n\ +原始 Markdown:\n{content}" + ) +} + +fn build_polish_review_prompt( + title: &str, + review_type: &str, + rating: i32, + review_date: Option<&str>, + status: &str, + tags: &[String], + description: &str, +) -> String { + let review_date_text = review_date + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("未填写"); + let tag_text = if tags.is_empty() { + "无".to_string() + } else { + tags.join(" / ") + }; + + format!( + "请润色一段中文作品评测简介/点评文案。\n\ +要求:\n\ +1. 保留原文观点、倾向和结论,不要杜撰剧情、设定或体验细节。\n\ +2. 语言更凝练、更自然,适合放在评测页中展示。\n\ +3. 可以优化句式与节奏,但不要改写成标题、列表或营销文案。\n\ +4. 默认输出一到三段正文,总长度尽量控制在 80 到 220 字之间;如果原文本身更长,可适度保留信息密度。\n\ +5. 只返回润色后的简介正文,不要附加解释。\n\n\ +作品标题:{}\n\ +评测类型:{}\n\ +评分:{}/5\n\ +评测日期:{}\n\ +状态:{}\n\ +标签:{}\n\n\ +当前简介:\n{}", + title.trim(), + review_type.trim(), + rating, + review_date_text, + status.trim(), + tag_text, + description.trim(), + ) +} + +fn ensure_sentence_ending(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + return String::new(); + } + + if matches!( + trimmed.chars().last(), + Some('。' | '!' | '?' | '.' | '!' | '?') + ) { + trimmed.to_string() + } else { + format!("{trimmed}。") + } +} + +fn build_post_cover_prompt( + title: &str, + description: Option<&str>, + category: Option<&str>, + tags: &[String], + post_type: &str, + markdown: &str, +) -> String { + let excerpt = strip_markdown_frontmatter(markdown) + .chars() + .take(1600) + .collect::(); + let description_text = description + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("暂无摘要"); + let category_text = category + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("未分类"); + let tag_text = if tags.is_empty() { + "无".to_string() + } else { + tags.join(" / ") + }; + let post_type_text = if post_type.trim().is_empty() { + "article" + } else { + post_type.trim() + }; + + format!( + "为一篇中文技术博客生成横版封面图。\n\ +要求:\n\ +1. 画面比例 16:9,适合作为文章头图。\n\ +2. 风格偏终端审美、现代、克制、有设计感,不要廉价素材感。\n\ +3. 不要在图片里放可读文字、logo、水印、UI 截图。\n\ +4. 画面要能体现文章主题,但保持抽象和高级感。\n\ +5. 适合中文技术博客首页与文章页展示。\n\n\ +文章标题:{title}\n\ +文章摘要:{description_text}\n\ +分类:{category_text}\n\ +标签:{tag_text}\n\ +文章类型:{post_type_text}\n\n\ +正文摘录:\n{excerpt}" + ) +} + +fn build_image_generation_url(provider: &str, api_base: &str, image_model: &str) -> String { + let normalized = normalize_provider_api_base(provider, api_base); + + if provider_uses_cloudflare(provider) { + return build_endpoint(&normalized, &format!("/ai/run/{}", image_model.trim())); + } + + build_endpoint(&normalized, "/images/generations") +} + +fn extract_image_generation_result(value: &Value) -> Option { + if let Some(result) = value.get("result") { + if let Some(image) = result.get("image").and_then(Value::as_str) { + let trimmed = image.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + if let Some(url) = result.get("url").and_then(Value::as_str) { + let trimmed = url.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + if let Some(image) = value.get("image").and_then(Value::as_str) { + let trimmed = image.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + let data = value.get("data").and_then(Value::as_array)?; + + for item in data { + if let Some(url) = item.get("url").and_then(Value::as_str) { + let trimmed = url.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + if let Some(base64_data) = item.get("b64_json").and_then(Value::as_str) { + let trimmed = base64_data.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + None +} + +fn generated_cover_directory() -> PathBuf { + let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let candidates = [ + current_dir + .join("frontend") + .join("public") + .join("generated-covers"), + current_dir + .join("..") + .join("frontend") + .join("public") + .join("generated-covers"), + ]; + + candidates + .into_iter() + .find(|path| path.parent().map(|parent| parent.exists()).unwrap_or(false)) + .unwrap_or_else(|| { + PathBuf::from("..") + .join("frontend") + .join("public") + .join("generated-covers") + }) +} + +fn image_details_from_mime(content_type: &str) -> Option<(&'static str, &'static str)> { + match content_type + .trim() + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "image/png" => Some(("png", "image/png")), + "image/jpeg" | "image/jpg" => Some(("jpg", "image/jpeg")), + "image/webp" => Some(("webp", "image/webp")), + "image/gif" => Some(("gif", "image/gif")), + "image/svg+xml" => Some(("svg", "image/svg+xml")), + _ => None, + } +} + +fn image_details_from_extension(extension: &str) -> Option<(&'static str, &'static str)> { + match extension + .trim() + .trim_start_matches('.') + .to_ascii_lowercase() + .as_str() + { + "png" => Some(("png", "image/png")), + "jpg" | "jpeg" => Some(("jpg", "image/jpeg")), + "webp" => Some(("webp", "image/webp")), + "gif" => Some(("gif", "image/gif")), + "svg" => Some(("svg", "image/svg+xml")), + _ => None, + } +} + +fn image_details_from_url(url: &str) -> Option<(&'static str, &'static str)> { + let parsed = Url::parse(url).ok()?; + let path = parsed.path(); + let extension = path.rsplit_once('.')?.1; + image_details_from_extension(extension) +} + +fn decode_base64_image_payload(base64_data: &str) -> Result<(Vec, &'static str, &'static str)> { + let trimmed = base64_data.trim(); + let (payload, extension, content_type) = if let Some(rest) = trimmed.strip_prefix("data:") { + let (metadata, encoded) = rest + .split_once(',') + .ok_or_else(|| Error::BadRequest("AI 封面图 data URL 格式不正确".to_string()))?; + let mime = metadata + .split(';') + .next() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("image/png"); + let (extension, content_type) = + image_details_from_mime(mime).unwrap_or(("png", "image/png")); + (encoded, extension, content_type) + } else { + (trimmed, "png", "image/png") + }; + + let image_bytes = BASE64_STANDARD + .decode(payload) + .map_err(|error| Error::BadRequest(format!("解析 AI 封面图失败: {error}")))?; + + Ok((image_bytes, extension, content_type)) +} + +async fn persist_generated_cover_image_bytes( + ctx: &AppContext, + slug_hint: &str, + image_bytes: Vec, + extension: &str, + content_type: &str, +) -> Result { + if storage::optional_r2_settings(ctx).await?.is_some() { + let key = storage::build_object_key("post-covers", slug_hint, extension); + let stored = storage::upload_bytes_to_r2( + ctx, + &key, + image_bytes, + Some(content_type), + Some("public, max-age=31536000, immutable"), + ) + .await?; + + return Ok(stored.url); + } + + let directory = generated_cover_directory(); + fs::create_dir_all(&directory) + .map_err(|error| Error::BadRequest(format!("创建封面图目录失败: {error}")))?; + + let safe_slug = metadata_slugify(slug_hint); + let file_name = format!( + "{}-{}.{}", + if safe_slug.is_empty() { + "cover".to_string() + } else { + safe_slug + }, + Uuid::new_v4().simple(), + extension + .trim() + .trim_start_matches('.') + .to_ascii_lowercase() + ); + let file_path = directory.join(&file_name); + + fs::write(&file_path, image_bytes) + .map_err(|error| Error::BadRequest(format!("写入 AI 封面图失败: {error}")))?; + + Ok(format!("/generated-covers/{file_name}")) +} + +async fn persist_generated_cover_image( + ctx: &AppContext, + slug_hint: &str, + image_result: &str, +) -> Result { + if image_result.starts_with("http://") || image_result.starts_with("https://") { + let client = Client::new(); + let response = client + .get(image_result) + .send() + .await + .map_err(|error| Error::BadRequest(format!("下载 AI 封面图失败: {error}")))? + .error_for_status() + .map_err(|error| Error::BadRequest(format!("下载 AI 封面图失败: {error}")))?; + let content_type_header = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + let image_bytes = response + .bytes() + .await + .map_err(|error| Error::BadRequest(format!("读取 AI 封面图失败: {error}")))?; + let (extension, content_type) = content_type_header + .as_deref() + .and_then(image_details_from_mime) + .or_else(|| image_details_from_url(image_result)) + .unwrap_or(("png", "image/png")); + + return persist_generated_cover_image_bytes( + ctx, + slug_hint, + image_bytes.to_vec(), + extension, + content_type, + ) + .await; + } + + let (image_bytes, extension, content_type) = decode_base64_image_payload(image_result)?; + persist_generated_cover_image_bytes(ctx, slug_hint, image_bytes, extension, content_type).await +} + +fn fallback_polished_markdown(markdown: &str) -> String { + let metadata = fallback_generated_metadata(markdown); + let body = strip_markdown_frontmatter(markdown) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + .replace("\n\n\n", "\n\n"); + + format!( + "---\n\ +title: {}\n\ +slug: {}\n\ +description: {}\n\ +category: {}\n\ +post_type: \"article\"\n\ +pinned: false\n\ +published: true\n\ +tags:\n{}\n\ +---\n\n{}\n", + serde_json::to_string(&metadata.title).unwrap_or_else(|_| "\"未命名文章\"".to_string()), + metadata.slug, + serde_json::to_string(&metadata.description) + .unwrap_or_else(|_| "\"关于文章内容的摘要。\"".to_string()), + serde_json::to_string(&metadata.category).unwrap_or_else(|_| "\"开发\"".to_string()), + metadata + .tags + .iter() + .map(|tag| format!(" - {}", serde_json::to_string(tag).unwrap_or_default())) + .collect::>() + .join("\n"), + body.trim() + ) +} + +fn fallback_polished_review_description(description: &str) -> String { + let normalized = normalize_newlines(description) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n\n"); + + ensure_sentence_ending(&normalized) +} + +pub async fn generate_post_metadata( + ctx: &AppContext, + markdown: &str, +) -> Result { + let trimmed_markdown = markdown.trim(); + if trimmed_markdown.is_empty() { + return Err(Error::BadRequest("文章内容不能为空".to_string())); + } + + let settings = load_runtime_settings(ctx, false).await?; + let remote_result: Result = match ( + settings.api_base.clone(), + settings.api_key.clone(), + ) { + (Some(api_base), Some(api_key)) => { + let request = AiProviderRequest { + provider: settings.provider.clone(), + api_base, + api_key, + chat_model: settings.chat_model.clone(), + system_prompt: "你是博客后台的内容编辑助手。你只负责提取和整理文章元数据,输出必须是合法 JSON。不要输出额外解释。".to_string(), + prompt: build_post_metadata_prompt(trimmed_markdown), + }; + + let client = Client::new(); + let response = request_json( + &client, + &build_provider_url(&request), + &request.api_key, + build_provider_payload(&request, false), + ) + .await; + + match response { + Ok(response) => { + let text = extract_provider_text(&response).ok_or_else(|| { + Error::BadRequest("AI 元数据响应里没有可读取内容。".to_string()) + })?; + let json_block = take_json_block(&text).ok_or_else(|| { + Error::BadRequest("AI 返回的元数据不是合法 JSON。".to_string()) + })?; + let draft = serde_json::from_str::(&json_block) + .map_err(|error| { + Error::BadRequest(format!("AI 元数据解析失败: {error}")) + })?; + + Ok(normalize_generated_metadata(trimmed_markdown, draft)) + } + Err(error) => Err(error), + } + } + _ => Err(Error::BadRequest( + "AI 服务未配置完整,已自动切换为本地智能推断。".to_string(), + )), + }; + + match remote_result { + Ok(metadata) => Ok(metadata), + Err(error) => { + tracing::warn!("AI metadata generation fallback: {error}"); + Ok(fallback_generated_metadata(trimmed_markdown)) + } + } +} + +pub async fn polish_post_markdown( + ctx: &AppContext, + markdown: &str, +) -> Result { + let trimmed_markdown = markdown.trim(); + if trimmed_markdown.is_empty() { + return Err(Error::BadRequest("文章内容不能为空".to_string())); + } + + let settings = load_runtime_settings(ctx, false).await?; + let remote_result: Result = match ( + settings.api_base.clone(), + settings.api_key.clone(), + ) { + (Some(api_base), Some(api_key)) => { + let request = AiProviderRequest { + provider: settings.provider.clone(), + api_base, + api_key, + chat_model: settings.chat_model.clone(), + system_prompt: "你是博客后台的资深编辑。你的任务是把用户给出的 Markdown 文档润色成更适合发布的版本,并且只返回完整 Markdown。".to_string(), + prompt: build_polish_markdown_prompt(trimmed_markdown), + }; + + let client = Client::new(); + let response = request_json( + &client, + &build_provider_url(&request), + &request.api_key, + build_provider_payload(&request, false), + ) + .await; + + match response { + Ok(response) => { + let polished_markdown = extract_provider_text(&response).ok_or_else(|| { + Error::BadRequest("AI 润色响应里没有可读取内容。".to_string()) + })?; + + Ok(PolishedPostMarkdown { + polished_markdown: normalize_newlines(&polished_markdown), + }) + } + Err(error) => Err(error), + } + } + _ => Err(Error::BadRequest( + "AI 服务未配置完整,已自动切换为本地智能推断。".to_string(), + )), + }; + + match remote_result { + Ok(result) => Ok(result), + Err(error) => { + tracing::warn!("AI post polish fallback: {error}"); + Ok(PolishedPostMarkdown { + polished_markdown: fallback_polished_markdown(trimmed_markdown), + }) + } + } +} + +pub async fn polish_review_description( + ctx: &AppContext, + title: &str, + review_type: &str, + rating: i32, + review_date: Option<&str>, + status: &str, + tags: &[String], + description: &str, +) -> Result { + let trimmed_description = description.trim(); + if trimmed_description.is_empty() { + return Err(Error::BadRequest( + "请先填写点评内容,再进行润色。".to_string(), + )); + } + + let settings = load_runtime_settings(ctx, false).await?; + let remote_result: Result = match ( + settings.api_base.clone(), + settings.api_key.clone(), + ) { + (Some(api_base), Some(api_key)) => { + let request = AiProviderRequest { + provider: settings.provider.clone(), + api_base, + api_key, + chat_model: settings.chat_model.clone(), + system_prompt: "你是中文内容平台里的资深评测编辑。你只负责润色用户已有的作品点评文案,不要改写核心观点,不要虚构事实,不要输出额外解释。".to_string(), + prompt: build_polish_review_prompt( + title, + review_type, + rating, + review_date, + status, + tags, + trimmed_description, + ), + }; + + let client = Client::new(); + let response = request_json( + &client, + &build_provider_url(&request), + &request.api_key, + build_provider_payload(&request, false), + ) + .await; + + match response { + Ok(response) => { + let polished_description = + extract_provider_text(&response).ok_or_else(|| { + Error::BadRequest("AI 润色响应里没有可读取内容。".to_string()) + })?; + + Ok(PolishedReviewDescription { + polished_description: normalize_newlines(polished_description.trim()), + }) + } + Err(error) => Err(error), + } + } + _ => Err(Error::BadRequest( + "AI 服务未配置完整,已自动切换为本地智能润色。".to_string(), + )), + }; + + match remote_result { + Ok(result) => Ok(result), + Err(error) => { + tracing::warn!("AI review polish fallback: {error}"); + Ok(PolishedReviewDescription { + polished_description: fallback_polished_review_description(trimmed_description), + }) + } + } +} + +pub async fn generate_post_cover_image( + ctx: &AppContext, + title: &str, + description: Option<&str>, + category: Option<&str>, + tags: &[String], + post_type: &str, + slug: Option<&str>, + markdown: &str, +) -> Result { + let trimmed_title = title.trim(); + let trimmed_markdown = markdown.trim(); + + if trimmed_title.is_empty() && trimmed_markdown.is_empty() { + return Err(Error::BadRequest( + "请至少填写标题或正文,再生成封面图。".to_string(), + )); + } + + let settings = load_runtime_settings(ctx, false).await?; + let image_settings = settings.image.clone(); + let api_base = image_settings.api_base.clone().ok_or_else(|| { + Error::BadRequest("图片 AI API Base 未配置,无法生成封面图。".to_string()) + })?; + let api_key = image_settings + .api_key + .clone() + .ok_or_else(|| Error::BadRequest("图片 AI API Key 未配置,无法生成封面图。".to_string()))?; + let prompt = build_post_cover_prompt( + if trimmed_title.is_empty() { + "未命名文章" + } else { + trimmed_title + }, + description, + category, + tags, + post_type, + trimmed_markdown, + ); + let image_model = image_settings.image_model.clone(); + let response = request_image_generation( + &image_settings.provider, + &api_base, + &api_key, + &image_model, + &prompt, + ) + .await?; + let image_result = extract_image_generation_result(&response) + .ok_or_else(|| Error::BadRequest("AI 封面图响应里没有可读取图片。".to_string()))?; + let slug_hint = slug + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(trimmed_title); + let image_url = persist_generated_cover_image(ctx, slug_hint, &image_result).await?; + + Ok(GeneratedPostCoverImage { image_url, prompt }) +} + +fn extract_response_output(value: &Value) -> Option { + if let Some(text) = value.get("output_text").and_then(Value::as_str) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + if let Some(text) = value.get("text").and_then(Value::as_str) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + if let Some(result) = value.get("result") { + if let Some(text) = extract_response_output(result) { + return Some(text); + } + } + + if let Some(response) = value.get("response") { + if let Some(text) = extract_response_output(response) { + return Some(text); + } + } + + if let Some(item) = value.get("item") { + if let Some(text) = extract_response_output(item) { + return Some(text); + } + } + + if let Some(part) = value.get("part") { + if let Some(text) = extract_response_output(part) { + return Some(text); + } + } + + if let Some(content_items) = value.get("content").and_then(Value::as_array) { + if let Some(text) = extract_text_from_content_items(content_items) { + return Some(text); + } + } + + let output_items = value.get("output").and_then(Value::as_array)?; + let mut segments = Vec::new(); + + for item in output_items { + if let Some(content_items) = item.get("content").and_then(Value::as_array) { + if let Some(text) = extract_text_from_content_items(content_items) { + segments.push(text); + } + } + } + + merge_text_segments(segments) +} + +fn build_chat_prompt(question: &str, matches: &[ScoredChunk]) -> String { + let context_blocks = matches + .iter() + .enumerate() + .map(|(index, item)| { + format!( + "[资料 {}]\n标题: {}\nSlug: {}\n相似度: {:.4}\n内容:\n{}", + index + 1, + item.row + .source_title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("未命名内容"), + item.row.source_slug, + item.score, + item.row.content + ) + }) + .collect::>() + .join("\n\n"); + + format!( + "请仅根据下面提供的资料回答用户问题。\n\ +如果资料不足以支撑结论,请直接说明“我在当前博客资料里没有找到足够信息”。\n\ +回答要求:\n\ +1. 使用中文。\n\ +2. 使用 Markdown 输出,必要时用短列表或小标题,不要输出 HTML。\n\ +3. 先给直接结论,再补充关键点,整体尽量精炼。\n\ +4. 不要编造未在资料中出现的事实。\n\ +5. 如果回答引用了具体资料,可自然地提及文章标题。\n\n\ +用户问题:{question}\n\n\ +可用资料:\n{context_blocks}" + ) +} + +fn is_profile_question(question: &str) -> bool { + let normalized = question.trim().to_lowercase(); + [ + "站长", + "博主", + "作者", + "个人介绍", + "个人信息", + "技术栈", + "github", + "邮箱", + "联系方式", + "init.cool", + "owner", + "author", + "maintainer", + "profile", + "tech stack", + "who runs", + "who built", + ] + .iter() + .any(|keyword| normalized.contains(keyword)) +} + +async fn prioritize_profile_matches( + ctx: &AppContext, + question: &str, + matches: Vec, + limit: usize, +) -> Result> { + if !is_profile_question(question) { + return Ok(matches); + } + + let statement = Statement::from_sql_and_values( + DbBackend::Postgres, + r#" + SELECT + source_slug, + source_title, + source_type, + chunk_index, + content, + content_preview, + word_count, + 1.0::float8 AS score + FROM ai_chunks + WHERE source_type = $1 + ORDER BY chunk_index ASC + LIMIT $2 + "#, + ["profile".into(), (limit as i64).into()], + ); + + let profile_rows = SimilarChunkRow::find_by_statement(statement) + .all(&ctx.db) + .await? + .into_iter() + .map(|row| ScoredChunk { + score: row.score, + row: ai_chunks::Model { + created_at: Utc::now().into(), + updated_at: Utc::now().into(), + id: 0, + source_slug: row.source_slug, + source_title: row.source_title, + source_path: None, + source_type: row.source_type, + chunk_index: row.chunk_index, + content: row.content, + content_preview: row.content_preview, + embedding: None, + word_count: row.word_count, + }, + }) + .collect::>(); + + if profile_rows.is_empty() { + return Ok(matches); + } + + let mut merged = profile_rows; + + for item in matches { + let duplicated = merged.iter().any(|existing| { + existing.row.source_slug == item.row.source_slug + && existing.row.chunk_index == item.row.chunk_index + }); + + if !duplicated { + merged.push(item); + } + } + + merged.truncate(limit); + Ok(merged) +} + +fn build_sources(matches: &[ScoredChunk]) -> Vec { + matches + .iter() + .map(|item| AiSource { + slug: item.row.source_slug.clone(), + href: if item.row.source_type == "profile" { + "/about".to_string() + } else { + format!("/articles/{}", item.row.source_slug) + }, + title: item + .row + .source_title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("未命名内容") + .to_string(), + excerpt: item + .row + .content_preview + .clone() + .unwrap_or_else(|| preview_text(&item.row.content, 180).unwrap_or_default()), + score: (item.score * 10000.0).round() / 10000.0, + chunk_index: item.row.chunk_index, + }) + .collect::>() +} + +pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value { + if provider_uses_cloudflare(&request.provider) { + json!({ + "prompt": format!( + "系统指令:{} + +用户请求:{}", + request.system_prompt, request.prompt, + ) + }) + } else if provider_uses_responses(&request.provider) { + json!({ + "model": request.chat_model, + "input": [ + { + "role": "system", + "content": [ + { + "type": "input_text", + "text": request.system_prompt + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": request.prompt + } + ] + } + ], + "reasoning": { + "effort": DEFAULT_REASONING_EFFORT + }, + "store": !DEFAULT_DISABLE_RESPONSE_STORAGE, + "stream": stream + }) + } else { + json!({ + "model": request.chat_model, + "temperature": 0.2, + "stream": stream, + "messages": [ + { + "role": "system", + "content": request.system_prompt, + }, + { + "role": "user", + "content": request.prompt, + } + ] + }) + } +} + +pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String { + let api_base = normalize_provider_api_base(&request.provider, &request.api_base); + let path = if provider_uses_cloudflare(&request.provider) { + format!("/ai/run/{}", request.chat_model.trim()) + } else if provider_uses_responses(&request.provider) { + "/responses".to_string() + } else { + "/chat/completions".to_string() + }; + + build_endpoint(&api_base, &path) +} + +#[cfg(test)] +mod tests { + use super::{ + build_provider_url, extract_provider_text, is_profile_question, + normalize_provider_api_base, parse_provider_sse_body, AiProviderRequest, + }; + + fn build_request(provider: &str, api_base: &str) -> AiProviderRequest { + AiProviderRequest { + provider: provider.to_string(), + api_base: api_base.to_string(), + api_key: "test-key".to_string(), + chat_model: "gpt-5.4".to_string(), + system_prompt: "system".to_string(), + prompt: "hello".to_string(), + } + } + + #[test] + fn normalize_provider_api_base_adds_v1_for_root_openai_style_urls() { + assert_eq!( + normalize_provider_api_base("newapi", "https://91code.jiangnight.com"), + "https://91code.jiangnight.com/v1" + ); + } + + #[test] + fn normalize_provider_api_base_keeps_existing_version_path() { + assert_eq!( + normalize_provider_api_base("newapi", "https://91code.jiangnight.com/v1/"), + "https://91code.jiangnight.com/v1" + ); + } + + #[test] + fn normalize_provider_api_base_preserves_custom_subpaths() { + assert_eq!( + normalize_provider_api_base("openai-compatible", "https://proxy.example.com/openai"), + "https://proxy.example.com/openai" + ); + } + + #[test] + fn build_provider_url_uses_normalized_base_for_responses_api() { + let request = build_request("newapi", "https://91code.jiangnight.com"); + + assert_eq!( + build_provider_url(&request), + "https://91code.jiangnight.com/v1/responses" + ); + } + + #[test] + fn normalize_provider_api_base_supports_cloudflare_account_id() { + assert_eq!( + normalize_provider_api_base("cloudflare", "test-account-id"), + "https://api.cloudflare.com/client/v4/accounts/test-account-id" + ); + } + + #[test] + fn build_provider_url_uses_cloudflare_run_endpoint() { + let mut request = build_request("cloudflare", "test-account-id"); + request.chat_model = "@cf/meta/llama-3.1-8b-instruct".to_string(); + + assert_eq!( + build_provider_url(&request), + "https://api.cloudflare.com/client/v4/accounts/test-account-id/ai/run/@cf/meta/llama-3.1-8b-instruct" + ); + } + + #[test] + fn profile_question_detects_owner_keywords() { + assert!(is_profile_question("站长的技术栈和个人介绍是什么?")); + assert!(is_profile_question("Who runs init.cool?")); + } + + #[test] + fn parse_provider_sse_body_extracts_final_response_payload() { + let body = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"output\":[]}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}]}}\n\n" + ); + + let parsed = parse_provider_sse_body(body).expect("expected SSE body to parse"); + + assert_eq!(parsed["id"], "resp_1"); + assert_eq!(parsed["output"][0]["content"][0]["text"], "ok"); + } + + #[test] + fn extract_provider_text_reads_completed_response_payload() { + let payload = serde_json::json!({ + "response": { + "output": [ + { + "type": "message", + "content": [ + { + "type": "output_text", + "text": "ok" + } + ] + } + ] + } + }); + + assert_eq!(extract_provider_text(&payload).as_deref(), Some("ok")); + } + + #[test] + fn extract_provider_text_reads_output_item_done_payload() { + let payload = serde_json::json!({ + "item": { + "type": "message", + "content": [ + { + "type": "output_text", + "text": "hello" + } + ] + } + }); + + assert_eq!(extract_provider_text(&payload).as_deref(), Some("hello")); + } +} + +pub(crate) fn extract_provider_text(value: &Value) -> Option { + extract_response_output(value).or_else(|| extract_message_content(value)) +} + +async fn request_chat_answer(request: &AiProviderRequest) -> Result { + let client = Client::new(); + let response = request_json( + &client, + &build_provider_url(request), + &request.api_key, + build_provider_payload(request, false), + ) + .await?; + + extract_provider_text(&response).ok_or_else(|| { + Error::BadRequest("AI chat response did not contain readable content".to_string()) + }) +} + +async fn request_image_generation( + provider: &str, + api_base: &str, + api_key: &str, + image_model: &str, + prompt: &str, +) -> Result { + let client = Client::new(); + + if provider_uses_cloudflare(provider) { + if image_model.eq_ignore_ascii_case(DEFAULT_CLOUDFLARE_IMAGE_MODEL) { + let form = multipart::Form::new() + .text("prompt", prompt.to_string()) + .text("width", "1024") + .text("height", "576") + .text("steps", "16"); + + return request_multipart_json( + &client, + &build_image_generation_url(provider, api_base, image_model), + api_key, + form, + ) + .await; + } + + return request_json( + &client, + &build_image_generation_url(provider, api_base, image_model), + api_key, + json!({ + "prompt": prompt, + "steps": 4 + }), + ) + .await; + } + + request_json( + &client, + &build_image_generation_url(provider, api_base, image_model), + api_key, + json!({ + "model": image_model, + "prompt": prompt, + "size": "1536x1024", + "quality": "high", + "output_format": "png" + }), + ) + .await +} + +pub async fn test_provider_connectivity( + provider: &str, + api_base: &str, + api_key: &str, + chat_model: &str, +) -> Result { + let provider = trim_to_option(Some(provider.to_string())) + .unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string()); + let api_base = trim_to_option(Some(api_base.to_string())) + .ok_or_else(|| Error::BadRequest("请先填写 API 地址".to_string()))?; + let api_key = trim_to_option(Some(api_key.to_string())) + .ok_or_else(|| Error::BadRequest("请先填写 API 密钥".to_string()))?; + let chat_model = trim_to_option(Some(chat_model.to_string())) + .ok_or_else(|| Error::BadRequest("请先填写对话模型".to_string()))?; + + let request = AiProviderRequest { + provider: provider.clone(), + api_base, + api_key, + chat_model: chat_model.clone(), + system_prompt: "你是一个连通性检测助手。".to_string(), + prompt: "请只回复 pong".to_string(), + }; + let reply = request_chat_answer(&request).await?; + let reply_preview = reply.trim().chars().take(160).collect::(); + + Ok(AiProviderConnectivityResult { + provider, + endpoint: build_provider_url(&request), + chat_model, + reply_preview, + }) +} + +pub async fn test_image_provider_connectivity( + provider: &str, + api_base: &str, + api_key: &str, + image_model: &str, +) -> Result { + let provider = trim_to_option(Some(provider.to_string())) + .unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string()); + let api_base = trim_to_option(Some(api_base.to_string())) + .ok_or_else(|| Error::BadRequest("请先填写图片 API 地址".to_string()))?; + let api_key = trim_to_option(Some(api_key.to_string())) + .ok_or_else(|| Error::BadRequest("请先填写图片 API 密钥".to_string()))?; + let image_model = trim_to_option(Some(image_model.to_string())) + .ok_or_else(|| Error::BadRequest("请先填写图片模型".to_string()))?; + + let prompt = "Minimal abstract technology cover art, blue gradient, no text, no watermark"; + let response = if provider_uses_cloudflare(&provider) + && image_model.eq_ignore_ascii_case(DEFAULT_CLOUDFLARE_IMAGE_MODEL) + { + let client = Client::new(); + let form = multipart::Form::new() + .text("prompt", prompt.to_string()) + .text("width", "512") + .text("height", "288") + .text("steps", "4"); + + request_multipart_json( + &client, + &build_image_generation_url(&provider, &api_base, &image_model), + &api_key, + form, + ) + .await? + } else { + request_image_generation(&provider, &api_base, &api_key, &image_model, prompt).await? + }; + let image_result = extract_image_generation_result(&response) + .ok_or_else(|| Error::BadRequest("图片接口响应里没有可读取的图片结果".to_string()))?; + let result_preview = + if image_result.starts_with("http://") || image_result.starts_with("https://") { + image_result + } else { + format!("base64 image ok ({} chars)", image_result.len()) + }; + + Ok(AiImageProviderConnectivityResult { + provider: provider.clone(), + endpoint: build_image_generation_url(&provider, &api_base, &image_model), + image_model, + result_preview, + }) +} + +pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result { + let trimmed_question = question.trim(); + if trimmed_question.is_empty() { + return Err(Error::BadRequest("问题不能为空".to_string())); + } + + let settings = load_runtime_settings(ctx, true).await?; + let (matches, indexed_chunks, last_indexed_at) = + retrieve_matches(ctx, &settings, trimmed_question).await?; + + if matches.is_empty() { + return Ok(PreparedAiAnswer { + question: trimmed_question.to_string(), + provider_request: None, + immediate_answer: Some( + "我在当前博客资料里没有找到足够信息。你可以换个更具体的问题,或者先去后台重建一下 AI 索引。" + .to_string(), + ), + sources: Vec::new(), + indexed_chunks, + last_indexed_at, + }); + } + + let sources = build_sources(&matches); + let provider_request = match (settings.api_base.clone(), settings.api_key.clone()) { + (Some(api_base), Some(api_key)) => Some(AiProviderRequest { + provider: settings.provider.clone(), + api_base, + api_key, + chat_model: settings.chat_model.clone(), + system_prompt: settings.system_prompt.clone(), + prompt: build_chat_prompt(trimmed_question, &matches), + }), + _ => None, + }; + + let immediate_answer = provider_request + .is_none() + .then(|| retrieval_only_answer(&matches)); + + Ok(PreparedAiAnswer { + question: trimmed_question.to_string(), + provider_request, + immediate_answer, + sources, + indexed_chunks, + last_indexed_at, + }) +} + +fn retrieval_only_answer(matches: &[ScoredChunk]) -> String { + let summary = matches + .iter() + .take(3) + .map(|item| { + let title = item + .row + .source_title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("未命名内容"); + let excerpt = item + .row + .content_preview + .clone() + .unwrap_or_else(|| preview_text(&item.row.content, 120).unwrap_or_default()); + + format!("《{title}》: {excerpt}") + }) + .collect::>() + .join("\n"); + + format!( + "本地知识检索已经完成,但后台还没有配置聊天模型 API,所以我先返回最相关的资料摘要:\n{summary}\n\n\ +如果你希望得到完整的自然语言回答,请在后台补上聊天模型的 API Base / API Key。" + ) +} + +async fn load_runtime_settings( + ctx: &AppContext, + require_enabled: bool, +) -> Result { + let raw = site_settings::Entity::find() + .order_by_asc(site_settings::Column::Id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + + if require_enabled && !raw.ai_enabled.unwrap_or(false) { + return Err(Error::NotFound); + } + + let active_provider = + site_settings_controller::active_ai_provider_id(&raw).and_then(|active_id| { + site_settings_controller::ai_provider_configs(&raw) + .into_iter() + .find(|item| item.id == active_id) + }); + let provider = active_provider + .as_ref() + .map(|item| provider_name(Some(item.provider.as_str()))) + .unwrap_or_else(|| provider_name(raw.ai_provider.as_deref())); + let api_base = active_provider + .as_ref() + .and_then(|item| trim_to_option(item.api_base.clone())) + .or_else(|| trim_to_option(raw.ai_api_base.clone())); + let api_key = active_provider + .as_ref() + .and_then(|item| trim_to_option(item.api_key.clone())) + .or_else(|| trim_to_option(raw.ai_api_key.clone())); + let chat_model = active_provider + .as_ref() + .and_then(|item| trim_to_option(item.chat_model.clone())) + .unwrap_or_else(|| { + if provider_uses_cloudflare(&provider) { + DEFAULT_CLOUDFLARE_CHAT_MODEL.to_string() + } else { + DEFAULT_CHAT_MODEL.to_string() + } + }); + let legacy_image_model = active_provider + .as_ref() + .and_then(|item| trim_to_option(item.image_model.clone())) + .unwrap_or_else(|| default_image_model_for_provider_name(&provider).to_string()); + let image_provider = trim_to_option(raw.ai_image_provider.clone()); + let image_api_base = trim_to_option(raw.ai_image_api_base.clone()); + let image_api_key = trim_to_option(raw.ai_image_api_key.clone()); + let image_model = trim_to_option(raw.ai_image_model.clone()); + let has_dedicated_image_settings = image_provider.is_some() + || image_api_base.is_some() + || image_api_key.is_some() + || image_model.is_some(); + let image = if has_dedicated_image_settings { + let provider = image_provider.unwrap_or_else(|| provider.clone()); + let image_model = image_model + .unwrap_or_else(|| default_image_model_for_provider_name(&provider).to_string()); + + AiImageRuntimeSettings { + provider, + api_base: image_api_base, + api_key: image_api_key, + image_model, + } + } else { + AiImageRuntimeSettings { + provider: provider.clone(), + api_base: api_base.clone(), + api_key: api_key.clone(), + image_model: legacy_image_model, + } + }; + + Ok(AiRuntimeSettings { + provider, + api_base, + api_key, + chat_model, + image, + system_prompt: trim_to_option(raw.ai_system_prompt.clone()) + .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()), + top_k: raw + .ai_top_k + .map(|value| value.clamp(1, 12) as usize) + .unwrap_or(DEFAULT_TOP_K), + chunk_size: raw + .ai_chunk_size + .map(|value| value.clamp(400, 4000) as usize) + .unwrap_or(DEFAULT_CHUNK_SIZE), + raw, + }) +} + +async fn update_indexed_at( + ctx: &AppContext, + settings: &site_settings::Model, +) -> Result> { + let now = Utc::now(); + let mut model = settings.clone().into_active_model(); + model.ai_last_indexed_at = Set(Some(now.into())); + let _ = model.update(&ctx.db).await?; + Ok(now) +} + +async fn retrieve_matches( + ctx: &AppContext, + settings: &AiRuntimeSettings, + question: &str, +) -> Result<(Vec, usize, Option>)> { + let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize; + let mut last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into); + + if indexed_chunks == 0 { + let summary = rebuild_index(ctx).await?; + indexed_chunks = summary.indexed_chunks; + last_indexed_at = summary.last_indexed_at; + } + + if indexed_chunks == 0 { + return Ok((Vec::new(), 0, last_indexed_at)); + } + + let question_embedding = + embed_texts_locally(vec![question.trim().to_string()], EmbeddingKind::Query) + .await? + .into_iter() + .next() + .unwrap_or_default(); + let query_vector = vector_literal(&question_embedding)?; + + let statement = Statement::from_sql_and_values( + DbBackend::Postgres, + r#" + SELECT + source_slug, + source_title, + source_type, + chunk_index, + content, + content_preview, + word_count, + (1 - (embedding <=> $1::vector))::float8 AS score + FROM ai_chunks + WHERE embedding IS NOT NULL + ORDER BY embedding <=> $1::vector + LIMIT $2 + "#, + [query_vector.into(), (settings.top_k as i64).into()], + ); + + let matches = SimilarChunkRow::find_by_statement(statement) + .all(&ctx.db) + .await? + .into_iter() + .map(|row| ScoredChunk { + score: row.score, + row: ai_chunks::Model { + created_at: Utc::now().into(), + updated_at: Utc::now().into(), + id: 0, + source_slug: row.source_slug, + source_title: row.source_title, + source_path: None, + source_type: row.source_type, + chunk_index: row.chunk_index, + content: row.content, + content_preview: row.content_preview, + embedding: None, + word_count: row.word_count, + }, + }) + .collect::>(); + + let matches = prioritize_profile_matches(ctx, question, matches, settings.top_k).await?; + + Ok((matches, indexed_chunks, last_indexed_at)) +} + +pub async fn rebuild_index(ctx: &AppContext) -> Result { + let settings = load_runtime_settings(ctx, false).await?; + let posts = content::sync_markdown_posts(ctx).await?; + let mut chunk_drafts = build_chunks(&posts, settings.chunk_size); + chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size)); + let embeddings = if chunk_drafts.is_empty() { + Vec::new() + } else { + embed_texts_locally( + chunk_drafts + .iter() + .map(|chunk| chunk.content.clone()) + .collect::>(), + EmbeddingKind::Passage, + ) + .await? + }; + + ctx.db + .execute(Statement::from_string( + DbBackend::Postgres, + "TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(), + )) + .await?; + + for (draft, embedding) in chunk_drafts.iter().zip(embeddings.into_iter()) { + let embedding_literal = vector_literal(&embedding)?; + let statement = Statement::from_sql_and_values( + DbBackend::Postgres, + r#" + INSERT INTO ai_chunks ( + source_slug, + source_title, + source_path, + source_type, + chunk_index, + content, + content_preview, + embedding, + word_count + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8::vector, $9 + ) + "#, + vec![ + draft.source_slug.clone().into(), + draft.source_title.clone().into(), + draft.source_path.clone().into(), + draft.source_type.clone().into(), + draft.chunk_index.into(), + draft.content.clone().into(), + draft.content_preview.clone().into(), + embedding_literal.into(), + draft.word_count.into(), + ], + ); + ctx.db.execute(statement).await?; + } + + let last_indexed_at = update_indexed_at(ctx, &settings.raw).await?; + + Ok(AiIndexSummary { + indexed_chunks: chunk_drafts.len(), + last_indexed_at: Some(last_indexed_at), + }) +} + +pub async fn answer_question(ctx: &AppContext, question: &str) -> Result { + let prepared = prepare_answer(ctx, question).await?; + let answer = if let Some(immediate_answer) = prepared.immediate_answer.clone() { + immediate_answer + } else { + let request = prepared + .provider_request + .as_ref() + .ok_or_else(|| Error::BadRequest("AI provider request was not prepared".to_string()))?; + request_chat_answer(request).await? + }; + + Ok(AiAnswer { + answer, + sources: prepared.sources, + indexed_chunks: prepared.indexed_chunks, + last_indexed_at: prepared.last_indexed_at, + }) +} + +pub async fn admin_chat_completion( + ctx: &AppContext, + system_prompt: &str, + prompt: &str, +) -> Result { + let settings = load_runtime_settings(ctx, false).await?; + let api_base = settings + .api_base + .ok_or_else(|| Error::BadRequest("请先在后台配置 AI API Base".to_string()))?; + let api_key = settings + .api_key + .ok_or_else(|| Error::BadRequest("请先在后台配置 AI API Key".to_string()))?; + + request_chat_answer(&AiProviderRequest { + provider: settings.provider, + api_base, + api_key, + chat_model: settings.chat_model, + system_prompt: system_prompt.trim().to_string(), + prompt: prompt.trim().to_string(), + }) + .await +} + +pub fn provider_name(value: Option<&str>) -> String { + trim_to_option(value.map(ToString::to_string)) + .unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string()) +} + +pub fn default_api_base() -> &'static str { + DEFAULT_AI_API_BASE +} + +pub fn default_api_key() -> &'static str { + DEFAULT_AI_API_KEY +} + +pub fn default_chat_model() -> &'static str { + DEFAULT_CHAT_MODEL +} + +pub fn local_embedding_label() -> &'static str { + LOCAL_EMBEDDING_MODEL_LABEL +} diff --git a/backend/src/services/analytics.rs b/backend/src/services/analytics.rs new file mode 100644 index 0000000..973e608 --- /dev/null +++ b/backend/src/services/analytics.rs @@ -0,0 +1,1176 @@ +use std::collections::{BTreeMap, HashMap}; + +use axum::http::HeaderMap; +use chrono::{DateTime, Duration, NaiveDate, Utc}; +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, + QuerySelect, Set, +}; +use serde::Serialize; + +use crate::models::_entities::{content_events, posts, query_events}; + +const EVENT_TYPE_SEARCH: &str = "search"; +const EVENT_TYPE_AI_QUESTION: &str = "ai_question"; +pub const CONTENT_EVENT_PAGE_VIEW: &str = "page_view"; +pub const CONTENT_EVENT_READ_PROGRESS: &str = "read_progress"; +pub const CONTENT_EVENT_READ_COMPLETE: &str = "read_complete"; + +#[derive(Clone, Debug, Default)] +pub struct QueryEventRequestContext { + pub request_path: Option, + pub referrer: Option, + pub user_agent: Option, +} + +#[derive(Clone, Debug)] +pub struct QueryEventDraft { + pub event_type: String, + pub query_text: String, + pub request_context: QueryEventRequestContext, + pub result_count: Option, + pub success: Option, + pub response_mode: Option, + pub provider: Option, + pub chat_model: Option, + pub latency_ms: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ContentEventRequestContext { + pub path: Option, + pub referrer: Option, + pub user_agent: Option, +} + +#[derive(Clone, Debug)] +pub struct ContentEventDraft { + pub event_type: String, + pub path: String, + pub post_slug: Option, + pub session_id: Option, + pub request_context: ContentEventRequestContext, + pub duration_ms: Option, + pub progress_percent: Option, + pub metadata: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AnalyticsOverview { + pub total_searches: u64, + pub total_ai_questions: u64, + pub searches_last_24h: u64, + pub ai_questions_last_24h: u64, + pub searches_last_7d: u64, + pub ai_questions_last_7d: u64, + pub unique_search_terms_last_7d: usize, + pub unique_ai_questions_last_7d: usize, + pub avg_search_results_last_7d: f64, + pub avg_ai_latency_ms_last_7d: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ContentAnalyticsOverview { + pub total_page_views: u64, + pub page_views_last_24h: u64, + pub page_views_last_7d: u64, + pub total_read_completes: u64, + pub read_completes_last_7d: u64, + pub avg_read_progress_last_7d: f64, + pub avg_read_duration_ms_last_7d: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AnalyticsTopQuery { + pub query: String, + pub count: u64, + pub last_seen_at: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AnalyticsRecentEvent { + pub id: i32, + pub event_type: String, + pub query: String, + pub result_count: Option, + pub success: Option, + pub response_mode: Option, + pub provider: Option, + pub chat_model: Option, + pub latency_ms: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AnalyticsProviderBucket { + pub provider: String, + pub count: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AnalyticsReferrerBucket { + pub referrer: String, + pub count: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AnalyticsPopularPost { + pub slug: String, + pub title: String, + pub page_views: u64, + pub read_completes: u64, + pub avg_progress_percent: f64, + pub avg_duration_ms: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AnalyticsDailyBucket { + pub date: String, + pub searches: u64, + pub ai_questions: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AdminAnalyticsResponse { + pub overview: AnalyticsOverview, + pub content_overview: ContentAnalyticsOverview, + pub top_search_terms: Vec, + pub top_ai_questions: Vec, + pub recent_events: Vec, + pub providers_last_7d: Vec, + pub top_referrers: Vec, + pub popular_posts: Vec, + pub daily_activity: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicContentHighlights { + pub overview: ContentAnalyticsOverview, + pub popular_posts: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicContentWindowOverview { + pub page_views: u64, + pub read_completes: u64, + pub avg_read_progress: f64, + pub avg_read_duration_ms: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicContentWindowHighlights { + pub key: String, + pub label: String, + pub days: i32, + pub overview: PublicContentWindowOverview, + pub popular_posts: Vec, +} + +#[derive(Clone, Debug)] +struct QueryAggregate { + query: String, + count: u64, + last_seen_at: DateTime, +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalize_query(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +fn format_timestamp(value: DateTime) -> String { + value.format("%Y-%m-%d %H:%M").to_string() +} + +fn normalize_referrer_source(value: Option) -> String { + let Some(value) = trim_to_option(value) else { + return "direct".to_string(); + }; + + reqwest::Url::parse(&value) + .ok() + .and_then(|url| url.host_str().map(ToString::to_string)) + .filter(|item| !item.trim().is_empty()) + .unwrap_or(value) +} + +fn header_value(headers: &HeaderMap, key: &str) -> Option { + headers + .get(key) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string) + .and_then(|value| trim_to_option(Some(value))) +} + +fn clamp_latency(latency_ms: i64) -> i32 { + latency_ms.clamp(0, i64::from(i32::MAX)) as i32 +} + +fn clamp_percentage(value: i32) -> i32 { + value.clamp(0, 100) +} + +fn build_query_aggregates( + events: &[query_events::Model], + wanted_type: &str, +) -> Vec { + let mut grouped: HashMap = HashMap::new(); + + for event in events + .iter() + .filter(|event| event.event_type == wanted_type) + { + let entry = grouped + .entry(event.normalized_query.clone()) + .or_insert_with(|| QueryAggregate { + query: event.query_text.clone(), + count: 0, + last_seen_at: event.created_at.into(), + }); + + entry.count += 1; + + let created_at = DateTime::::from(event.created_at); + if created_at >= entry.last_seen_at { + entry.query = event.query_text.clone(); + entry.last_seen_at = created_at; + } + } + + let mut items = grouped.into_values().collect::>(); + items.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| right.last_seen_at.cmp(&left.last_seen_at)) + }); + items +} + +fn aggregate_queries( + events: &[query_events::Model], + wanted_type: &str, + limit: usize, +) -> (usize, Vec) { + let aggregates = build_query_aggregates(events, wanted_type); + let total_unique = aggregates.len(); + let items = aggregates + .into_iter() + .take(limit) + .map(|item| AnalyticsTopQuery { + query: item.query, + count: item.count, + last_seen_at: format_timestamp(item.last_seen_at), + }) + .collect::>(); + + (total_unique, items) +} + +pub fn request_context_from_headers(path: &str, headers: &HeaderMap) -> QueryEventRequestContext { + QueryEventRequestContext { + request_path: trim_to_option(Some(path.to_string())), + referrer: header_value(headers, "referer"), + user_agent: header_value(headers, "user-agent"), + } +} + +pub fn content_request_context_from_headers( + path: &str, + headers: &HeaderMap, +) -> ContentEventRequestContext { + ContentEventRequestContext { + path: trim_to_option(Some(path.to_string())), + referrer: header_value(headers, "referer"), + user_agent: header_value(headers, "user-agent"), + } +} + +pub async fn record_event(ctx: &AppContext, draft: QueryEventDraft) { + let query_text = draft.query_text.trim().to_string(); + if query_text.is_empty() { + return; + } + + let active_model = query_events::ActiveModel { + event_type: Set(draft.event_type), + query_text: Set(query_text.clone()), + normalized_query: Set(normalize_query(&query_text)), + request_path: Set(trim_to_option(draft.request_context.request_path)), + referrer: Set(trim_to_option(draft.request_context.referrer)), + user_agent: Set(trim_to_option(draft.request_context.user_agent)), + result_count: Set(draft.result_count), + success: Set(draft.success), + response_mode: Set(trim_to_option(draft.response_mode)), + provider: Set(trim_to_option(draft.provider)), + chat_model: Set(trim_to_option(draft.chat_model)), + latency_ms: Set(draft.latency_ms.map(|value| value.max(0))), + ..Default::default() + }; + + if let Err(error) = active_model.insert(&ctx.db).await { + tracing::warn!("failed to record query analytics event: {error}"); + } +} + +pub async fn record_content_event(ctx: &AppContext, draft: ContentEventDraft) { + let path = draft.path.trim().to_string(); + if path.is_empty() { + return; + } + + let event_type = draft.event_type.trim().to_ascii_lowercase(); + if !matches!( + event_type.as_str(), + CONTENT_EVENT_PAGE_VIEW | CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE + ) { + return; + } + + let active_model = content_events::ActiveModel { + event_type: Set(event_type), + path: Set(path), + post_slug: Set(trim_to_option(draft.post_slug)), + session_id: Set(trim_to_option(draft.session_id)), + referrer: Set(trim_to_option(draft.request_context.referrer)), + user_agent: Set(trim_to_option(draft.request_context.user_agent)), + duration_ms: Set(draft.duration_ms.map(|value| value.max(0))), + progress_percent: Set(draft.progress_percent.map(clamp_percentage)), + metadata: Set(draft.metadata), + ..Default::default() + }; + + if let Err(error) = active_model.insert(&ctx.db).await { + tracing::warn!("failed to record content analytics event: {error}"); + } +} + +pub async fn record_search_event( + ctx: &AppContext, + query_text: &str, + result_count: usize, + headers: &HeaderMap, + latency_ms: i64, +) { + record_event( + ctx, + QueryEventDraft { + event_type: EVENT_TYPE_SEARCH.to_string(), + query_text: query_text.to_string(), + request_context: request_context_from_headers("/api/search", headers), + result_count: Some(result_count.min(i32::MAX as usize) as i32), + success: Some(true), + response_mode: None, + provider: None, + chat_model: None, + latency_ms: Some(clamp_latency(latency_ms)), + }, + ) + .await; +} + +pub async fn record_ai_question_event( + ctx: &AppContext, + question: &str, + headers: &HeaderMap, + success: bool, + response_mode: &str, + provider: Option, + chat_model: Option, + result_count: Option, + latency_ms: i64, +) { + record_event( + ctx, + QueryEventDraft { + event_type: EVENT_TYPE_AI_QUESTION.to_string(), + query_text: question.to_string(), + request_context: request_context_from_headers( + if response_mode == "stream" { + "/api/ai/ask/stream" + } else { + "/api/ai/ask" + }, + headers, + ), + result_count: result_count.map(|value| value.min(i32::MAX as usize) as i32), + success: Some(success), + response_mode: Some(response_mode.to_string()), + provider, + chat_model, + latency_ms: Some(clamp_latency(latency_ms)), + }, + ) + .await; +} + +pub async fn build_admin_analytics(ctx: &AppContext) -> Result { + let now = Utc::now(); + let since_24h = now - Duration::hours(24); + let since_7d = now - Duration::days(7); + + let total_searches = query_events::Entity::find() + .filter(query_events::Column::EventType.eq(EVENT_TYPE_SEARCH)) + .count(&ctx.db) + .await?; + let total_ai_questions = query_events::Entity::find() + .filter(query_events::Column::EventType.eq(EVENT_TYPE_AI_QUESTION)) + .count(&ctx.db) + .await?; + + let searches_last_24h = query_events::Entity::find() + .filter(query_events::Column::EventType.eq(EVENT_TYPE_SEARCH)) + .filter(query_events::Column::CreatedAt.gte(since_24h)) + .count(&ctx.db) + .await?; + let ai_questions_last_24h = query_events::Entity::find() + .filter(query_events::Column::EventType.eq(EVENT_TYPE_AI_QUESTION)) + .filter(query_events::Column::CreatedAt.gte(since_24h)) + .count(&ctx.db) + .await?; + let total_page_views = content_events::Entity::find() + .filter(content_events::Column::EventType.eq(CONTENT_EVENT_PAGE_VIEW)) + .count(&ctx.db) + .await?; + let total_read_completes = content_events::Entity::find() + .filter(content_events::Column::EventType.eq(CONTENT_EVENT_READ_COMPLETE)) + .count(&ctx.db) + .await?; + + let last_7d_events = query_events::Entity::find() + .filter(query_events::Column::CreatedAt.gte(since_7d)) + .order_by_desc(query_events::Column::CreatedAt) + .all(&ctx.db) + .await?; + let last_7d_content_events = content_events::Entity::find() + .filter(content_events::Column::CreatedAt.gte(since_7d)) + .order_by_desc(content_events::Column::CreatedAt) + .all(&ctx.db) + .await?; + + let searches_last_7d = last_7d_events + .iter() + .filter(|event| event.event_type == EVENT_TYPE_SEARCH) + .count() as u64; + let ai_questions_last_7d = last_7d_events + .iter() + .filter(|event| event.event_type == EVENT_TYPE_AI_QUESTION) + .count() as u64; + + let (unique_search_terms_last_7d, top_search_terms) = + aggregate_queries(&last_7d_events, EVENT_TYPE_SEARCH, 8); + let (unique_ai_questions_last_7d, top_ai_questions) = + aggregate_queries(&last_7d_events, EVENT_TYPE_AI_QUESTION, 8); + + let mut provider_breakdown: HashMap = HashMap::new(); + let mut daily_map: BTreeMap = BTreeMap::new(); + let mut total_search_results = 0.0_f64; + let mut counted_search_results = 0_u64; + let mut total_ai_latency = 0.0_f64; + let mut counted_ai_latency = 0_u64; + let mut referrer_breakdown: HashMap = HashMap::new(); + let mut total_read_progress = 0.0_f64; + let mut counted_read_progress = 0_u64; + let mut total_read_duration = 0.0_f64; + let mut counted_read_duration = 0_u64; + let mut page_views_last_24h = 0_u64; + let mut page_views_last_7d = 0_u64; + let mut read_completes_last_7d = 0_u64; + + for offset in 0..7 { + let date = (now - Duration::days(offset)).date_naive(); + daily_map.entry(date).or_insert((0, 0)); + } + + for event in &last_7d_events { + let day = DateTime::::from(event.created_at).date_naive(); + let entry = daily_map.entry(day).or_insert((0, 0)); + + if event.event_type == EVENT_TYPE_SEARCH { + entry.0 += 1; + if let Some(result_count) = event.result_count { + total_search_results += f64::from(result_count.max(0)); + counted_search_results += 1; + } + continue; + } + + if event.event_type == EVENT_TYPE_AI_QUESTION { + entry.1 += 1; + + let provider = event + .provider + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "local-or-unspecified".to_string()); + *provider_breakdown.entry(provider).or_insert(0) += 1; + + if let Some(latency_ms) = event.latency_ms { + total_ai_latency += f64::from(latency_ms.max(0)); + counted_ai_latency += 1; + } + } + } + + let post_titles = posts::Entity::find() + .all(&ctx.db) + .await? + .into_iter() + .map(|post| { + ( + post.slug, + post.title.unwrap_or_else(|| "Untitled post".to_string()), + ) + }) + .collect::>(); + + let mut post_breakdown: HashMap = HashMap::new(); + + for event in &last_7d_content_events { + let created_at = DateTime::::from(event.created_at); + + if event.event_type == CONTENT_EVENT_PAGE_VIEW { + page_views_last_7d += 1; + if created_at >= since_24h { + page_views_last_24h += 1; + } + + let referrer = normalize_referrer_source(event.referrer.clone()); + *referrer_breakdown.entry(referrer).or_insert(0) += 1; + } + + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + read_completes_last_7d += 1; + } + + if matches!( + event.event_type.as_str(), + CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE + ) { + let progress = event.progress_percent.unwrap_or({ + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + 100 + } else { + 0 + } + }); + if progress > 0 { + total_read_progress += f64::from(progress); + counted_read_progress += 1; + } + + if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) { + total_read_duration += f64::from(duration_ms); + counted_read_duration += 1; + } + } + + let Some(post_slug) = event + .post_slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + else { + continue; + }; + + let entry = post_breakdown + .entry(post_slug) + .or_insert((0, 0, 0.0, 0, 0.0, 0)); + + if event.event_type == CONTENT_EVENT_PAGE_VIEW { + entry.0 += 1; + } + + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + entry.1 += 1; + } + + if matches!( + event.event_type.as_str(), + CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE + ) { + let progress = event.progress_percent.unwrap_or({ + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + 100 + } else { + 0 + } + }); + if progress > 0 { + entry.2 += f64::from(progress); + entry.3 += 1; + } + + if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) { + entry.4 += f64::from(duration_ms); + entry.5 += 1; + } + } + } + + let mut providers_last_7d = provider_breakdown + .into_iter() + .map(|(provider, count)| AnalyticsProviderBucket { provider, count }) + .collect::>(); + providers_last_7d.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.provider.cmp(&right.provider)) + }); + providers_last_7d.truncate(6); + + let mut top_referrers = referrer_breakdown + .into_iter() + .map(|(referrer, count)| AnalyticsReferrerBucket { referrer, count }) + .collect::>(); + top_referrers.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.referrer.cmp(&right.referrer)) + }); + top_referrers.truncate(8); + + let mut popular_posts = post_breakdown + .into_iter() + .map( + |(slug, (page_views, read_completes, total_progress, progress_count, total_duration, duration_count))| { + AnalyticsPopularPost { + title: post_titles + .get(&slug) + .cloned() + .unwrap_or_else(|| slug.clone()), + slug, + page_views, + read_completes, + avg_progress_percent: if progress_count > 0 { + total_progress / progress_count as f64 + } else { + 0.0 + }, + avg_duration_ms: (duration_count > 0) + .then(|| total_duration / duration_count as f64), + } + }, + ) + .collect::>(); + popular_posts.sort_by(|left, right| { + right + .page_views + .cmp(&left.page_views) + .then_with(|| right.read_completes.cmp(&left.read_completes)) + .then_with(|| left.slug.cmp(&right.slug)) + }); + popular_posts.truncate(10); + + let mut daily_activity = daily_map + .into_iter() + .map(|(date, (searches, ai_questions))| AnalyticsDailyBucket { + date: date.format("%Y-%m-%d").to_string(), + searches, + ai_questions, + }) + .collect::>(); + daily_activity.sort_by(|left, right| left.date.cmp(&right.date)); + + let recent_events = query_events::Entity::find() + .order_by_desc(query_events::Column::CreatedAt) + .limit(24) + .all(&ctx.db) + .await? + .into_iter() + .map(|event| AnalyticsRecentEvent { + id: event.id, + event_type: event.event_type, + query: event.query_text, + result_count: event.result_count, + success: event.success, + response_mode: event.response_mode, + provider: event.provider, + chat_model: event.chat_model, + latency_ms: event.latency_ms, + created_at: format_timestamp(event.created_at.into()), + }) + .collect::>(); + + Ok(AdminAnalyticsResponse { + overview: AnalyticsOverview { + total_searches, + total_ai_questions, + searches_last_24h, + ai_questions_last_24h, + searches_last_7d, + ai_questions_last_7d, + unique_search_terms_last_7d, + unique_ai_questions_last_7d, + avg_search_results_last_7d: if counted_search_results > 0 { + total_search_results / counted_search_results as f64 + } else { + 0.0 + }, + avg_ai_latency_ms_last_7d: (counted_ai_latency > 0) + .then(|| total_ai_latency / counted_ai_latency as f64), + }, + content_overview: ContentAnalyticsOverview { + total_page_views, + page_views_last_24h, + page_views_last_7d, + total_read_completes, + read_completes_last_7d, + avg_read_progress_last_7d: if counted_read_progress > 0 { + total_read_progress / counted_read_progress as f64 + } else { + 0.0 + }, + avg_read_duration_ms_last_7d: (counted_read_duration > 0) + .then(|| total_read_duration / counted_read_duration as f64), + }, + top_search_terms, + top_ai_questions, + recent_events, + providers_last_7d, + top_referrers, + popular_posts, + daily_activity, + }) +} + +pub async fn build_public_content_highlights( + ctx: &AppContext, + public_posts: &[posts::Model], +) -> Result { + if public_posts.is_empty() { + return Ok(PublicContentHighlights { + overview: ContentAnalyticsOverview { + total_page_views: 0, + page_views_last_24h: 0, + page_views_last_7d: 0, + total_read_completes: 0, + read_completes_last_7d: 0, + avg_read_progress_last_7d: 0.0, + avg_read_duration_ms_last_7d: None, + }, + popular_posts: Vec::new(), + }); + } + + let now = Utc::now(); + let since_24h = now - Duration::hours(24); + let since_7d = now - Duration::days(7); + let public_slugs = public_posts + .iter() + .map(|post| post.slug.clone()) + .collect::>(); + let post_titles = public_posts + .iter() + .map(|post| { + ( + post.slug.clone(), + trim_to_option(post.title.clone()).unwrap_or_else(|| post.slug.clone()), + ) + }) + .collect::>(); + + let total_page_views = content_events::Entity::find() + .filter(content_events::Column::EventType.eq(CONTENT_EVENT_PAGE_VIEW)) + .filter(content_events::Column::PostSlug.is_in(public_slugs.clone())) + .count(&ctx.db) + .await?; + let total_read_completes = content_events::Entity::find() + .filter(content_events::Column::EventType.eq(CONTENT_EVENT_READ_COMPLETE)) + .filter(content_events::Column::PostSlug.is_in(public_slugs.clone())) + .count(&ctx.db) + .await?; + + let last_7d_content_events = content_events::Entity::find() + .filter(content_events::Column::CreatedAt.gte(since_7d)) + .filter(content_events::Column::PostSlug.is_in(public_slugs)) + .all(&ctx.db) + .await?; + + let mut page_views_last_24h = 0_u64; + let mut page_views_last_7d = 0_u64; + let mut read_completes_last_7d = 0_u64; + let mut total_read_progress = 0.0_f64; + let mut counted_read_progress = 0_u64; + let mut total_read_duration = 0.0_f64; + let mut counted_read_duration = 0_u64; + let mut post_breakdown = HashMap::::new(); + + for event in &last_7d_content_events { + let created_at = DateTime::::from(event.created_at); + let Some(post_slug) = event + .post_slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + else { + continue; + }; + + if event.event_type == CONTENT_EVENT_PAGE_VIEW { + page_views_last_7d += 1; + if created_at >= since_24h { + page_views_last_24h += 1; + } + } + + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + read_completes_last_7d += 1; + } + + if matches!( + event.event_type.as_str(), + CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE + ) { + let progress = event.progress_percent.unwrap_or({ + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + 100 + } else { + 0 + } + }); + if progress > 0 { + total_read_progress += f64::from(progress); + counted_read_progress += 1; + } + + if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) { + total_read_duration += f64::from(duration_ms); + counted_read_duration += 1; + } + } + + let entry = post_breakdown + .entry(post_slug) + .or_insert((0, 0, 0.0, 0, 0.0, 0)); + + if event.event_type == CONTENT_EVENT_PAGE_VIEW { + entry.0 += 1; + } + + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + entry.1 += 1; + } + + if matches!( + event.event_type.as_str(), + CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE + ) { + let progress = event.progress_percent.unwrap_or({ + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + 100 + } else { + 0 + } + }); + if progress > 0 { + entry.2 += f64::from(progress); + entry.3 += 1; + } + + if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) { + entry.4 += f64::from(duration_ms); + entry.5 += 1; + } + } + } + + let mut popular_posts = post_breakdown + .into_iter() + .map( + |( + slug, + ( + page_views, + read_completes, + total_progress, + progress_count, + total_duration, + duration_count, + ), + )| AnalyticsPopularPost { + title: post_titles + .get(&slug) + .cloned() + .unwrap_or_else(|| slug.clone()), + slug, + page_views, + read_completes, + avg_progress_percent: if progress_count > 0 { + total_progress / progress_count as f64 + } else { + 0.0 + }, + avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64), + }, + ) + .collect::>(); + popular_posts.sort_by(|left, right| { + right + .page_views + .cmp(&left.page_views) + .then_with(|| right.read_completes.cmp(&left.read_completes)) + .then_with(|| left.slug.cmp(&right.slug)) + }); + popular_posts.truncate(6); + + Ok(PublicContentHighlights { + overview: ContentAnalyticsOverview { + total_page_views, + page_views_last_24h, + page_views_last_7d, + total_read_completes, + read_completes_last_7d, + avg_read_progress_last_7d: if counted_read_progress > 0 { + total_read_progress / counted_read_progress as f64 + } else { + 0.0 + }, + avg_read_duration_ms_last_7d: (counted_read_duration > 0) + .then(|| total_read_duration / counted_read_duration as f64), + }, + popular_posts, + }) +} + +pub async fn build_public_content_windows( + ctx: &AppContext, + public_posts: &[posts::Model], +) -> Result> { + if public_posts.is_empty() { + return Ok(vec![ + build_empty_public_content_window("24h", "24h", 1), + build_empty_public_content_window("7d", "7d", 7), + build_empty_public_content_window("30d", "30d", 30), + ]); + } + + let now = Utc::now(); + let since_30d = now - Duration::days(30); + let public_slugs = public_posts + .iter() + .map(|post| post.slug.clone()) + .collect::>(); + let post_titles = public_posts + .iter() + .map(|post| { + ( + post.slug.clone(), + trim_to_option(post.title.clone()).unwrap_or_else(|| post.slug.clone()), + ) + }) + .collect::>(); + + let events = content_events::Entity::find() + .filter(content_events::Column::CreatedAt.gte(since_30d)) + .filter(content_events::Column::PostSlug.is_in(public_slugs)) + .all(&ctx.db) + .await?; + + Ok(vec![ + summarize_public_content_window(&events, &post_titles, now - Duration::hours(24), "24h", "24h", 1), + summarize_public_content_window(&events, &post_titles, now - Duration::days(7), "7d", "7d", 7), + summarize_public_content_window(&events, &post_titles, since_30d, "30d", "30d", 30), + ]) +} + +fn build_empty_public_content_window( + key: &str, + label: &str, + days: i32, +) -> PublicContentWindowHighlights { + PublicContentWindowHighlights { + key: key.to_string(), + label: label.to_string(), + days, + overview: PublicContentWindowOverview { + page_views: 0, + read_completes: 0, + avg_read_progress: 0.0, + avg_read_duration_ms: None, + }, + popular_posts: Vec::new(), + } +} + +fn summarize_public_content_window( + events: &[content_events::Model], + post_titles: &HashMap, + since: DateTime, + key: &str, + label: &str, + days: i32, +) -> PublicContentWindowHighlights { + let mut page_views = 0_u64; + let mut read_completes = 0_u64; + let mut total_read_progress = 0.0_f64; + let mut counted_read_progress = 0_u64; + let mut total_read_duration = 0.0_f64; + let mut counted_read_duration = 0_u64; + let mut post_breakdown = HashMap::::new(); + + for event in events { + let created_at = DateTime::::from(event.created_at); + if created_at < since { + continue; + } + + let Some(post_slug) = event + .post_slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + else { + continue; + }; + + if event.event_type == CONTENT_EVENT_PAGE_VIEW { + page_views += 1; + } + + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + read_completes += 1; + } + + if matches!( + event.event_type.as_str(), + CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE + ) { + let progress = event.progress_percent.unwrap_or({ + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + 100 + } else { + 0 + } + }); + if progress > 0 { + total_read_progress += f64::from(progress); + counted_read_progress += 1; + } + + if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) { + total_read_duration += f64::from(duration_ms); + counted_read_duration += 1; + } + } + + let entry = post_breakdown + .entry(post_slug) + .or_insert((0, 0, 0.0, 0, 0.0, 0)); + + if event.event_type == CONTENT_EVENT_PAGE_VIEW { + entry.0 += 1; + } + + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + entry.1 += 1; + } + + if matches!( + event.event_type.as_str(), + CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE + ) { + let progress = event.progress_percent.unwrap_or({ + if event.event_type == CONTENT_EVENT_READ_COMPLETE { + 100 + } else { + 0 + } + }); + if progress > 0 { + entry.2 += f64::from(progress); + entry.3 += 1; + } + + if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) { + entry.4 += f64::from(duration_ms); + entry.5 += 1; + } + } + } + + let mut popular_posts = post_breakdown + .into_iter() + .map( + |( + slug, + ( + item_page_views, + item_read_completes, + total_progress, + progress_count, + total_duration, + duration_count, + ), + )| AnalyticsPopularPost { + title: post_titles + .get(&slug) + .cloned() + .unwrap_or_else(|| slug.clone()), + slug, + page_views: item_page_views, + read_completes: item_read_completes, + avg_progress_percent: if progress_count > 0 { + total_progress / progress_count as f64 + } else { + 0.0 + }, + avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64), + }, + ) + .collect::>(); + + popular_posts.sort_by(|left, right| { + right + .page_views + .cmp(&left.page_views) + .then_with(|| right.read_completes.cmp(&left.read_completes)) + .then_with(|| { + right + .avg_progress_percent + .partial_cmp(&left.avg_progress_percent) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| left.slug.cmp(&right.slug)) + }); + popular_posts.truncate(6); + + PublicContentWindowHighlights { + key: key.to_string(), + label: label.to_string(), + days, + overview: PublicContentWindowOverview { + page_views, + read_completes, + avg_read_progress: if counted_read_progress > 0 { + total_read_progress / counted_read_progress as f64 + } else { + 0.0 + }, + avg_read_duration_ms: (counted_read_duration > 0) + .then(|| total_read_duration / counted_read_duration as f64), + }, + popular_posts, + } +} diff --git a/backend/src/services/comment_guard.rs b/backend/src/services/comment_guard.rs new file mode 100644 index 0000000..99972f9 --- /dev/null +++ b/backend/src/services/comment_guard.rs @@ -0,0 +1,375 @@ +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use chrono::{DateTime, Duration, Utc}; +use loco_rs::prelude::*; +use sea_orm::{ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}; +use serde::Serialize; +use uuid::Uuid; + +use crate::models::_entities::{comment_blacklist, comments}; + +const DEFAULT_RATE_LIMIT_WINDOW_SECONDS: i64 = 10 * 60; +const DEFAULT_RATE_LIMIT_MAX_PER_WINDOW: u64 = 8; +const DEFAULT_MIN_INTERVAL_SECONDS: i64 = 12; +const DEFAULT_CAPTCHA_TTL_SECONDS: i64 = 10 * 60; + +const ENV_RATE_LIMIT_WINDOW_SECONDS: &str = "TERMI_COMMENT_RATE_LIMIT_WINDOW_SECONDS"; +const ENV_RATE_LIMIT_MAX_PER_WINDOW: &str = "TERMI_COMMENT_RATE_LIMIT_MAX_PER_WINDOW"; +const ENV_MIN_INTERVAL_SECONDS: &str = "TERMI_COMMENT_MIN_INTERVAL_SECONDS"; +const ENV_BLOCK_KEYWORDS: &str = "TERMI_COMMENT_BLOCK_KEYWORDS"; +const ENV_CAPTCHA_TTL_SECONDS: &str = "TERMI_COMMENT_CAPTCHA_TTL_SECONDS"; + +pub const MATCHER_TYPE_IP: &str = "ip"; +pub const MATCHER_TYPE_EMAIL: &str = "email"; +pub const MATCHER_TYPE_USER_AGENT: &str = "user_agent"; + +#[derive(Clone, Debug, Serialize)] +pub struct CommentCaptchaChallenge { + pub token: String, + pub question: String, + pub expires_in_seconds: i64, +} + +#[derive(Clone, Debug)] +pub struct CommentGuardInput<'a> { + pub ip_address: Option<&'a str>, + pub email: Option<&'a str>, + pub user_agent: Option<&'a str>, + pub author: Option<&'a str>, + pub content: Option<&'a str>, + pub honeypot_website: Option<&'a str>, + pub captcha_token: Option<&'a str>, + pub captcha_answer: Option<&'a str>, +} + +#[derive(Clone, Debug)] +struct GuardConfig { + rate_limit_window_seconds: i64, + rate_limit_max_per_window: u64, + min_interval_seconds: i64, + blocked_keywords: Vec, + captcha_ttl_seconds: i64, +} + +#[derive(Clone, Debug)] +struct CaptchaEntry { + answer: String, + expires_at: DateTime, + ip_address: Option, +} + +fn parse_env_i64(name: &str, fallback: i64, min: i64, max: i64) -> i64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .map(|value| value.clamp(min, max)) + .unwrap_or(fallback) +} + +fn trim_to_option(value: Option<&str>) -> Option { + value.and_then(|item| { + let trimmed = item.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn normalize_email(value: Option<&str>) -> Option { + trim_to_option(value).map(|item| item.to_lowercase()) +} + +fn normalize_user_agent(value: Option<&str>) -> Option { + trim_to_option(value).map(|item| item.chars().take(512).collect::()) +} + +fn normalize_ip(value: Option<&str>) -> Option { + trim_to_option(value).map(|item| item.chars().take(96).collect::()) +} + +fn parse_keywords() -> Vec { + std::env::var(ENV_BLOCK_KEYWORDS) + .ok() + .map(|value| { + value + .split([',', '\n', '\r']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.to_lowercase()) + .collect::>() + }) + .unwrap_or_default() +} + +fn load_config() -> GuardConfig { + GuardConfig { + rate_limit_window_seconds: parse_env_i64( + ENV_RATE_LIMIT_WINDOW_SECONDS, + DEFAULT_RATE_LIMIT_WINDOW_SECONDS, + 10, + 24 * 60 * 60, + ), + rate_limit_max_per_window: parse_env_i64( + ENV_RATE_LIMIT_MAX_PER_WINDOW, + DEFAULT_RATE_LIMIT_MAX_PER_WINDOW as i64, + 1, + 500, + ) as u64, + min_interval_seconds: parse_env_i64( + ENV_MIN_INTERVAL_SECONDS, + DEFAULT_MIN_INTERVAL_SECONDS, + 0, + 6 * 60 * 60, + ), + blocked_keywords: parse_keywords(), + captcha_ttl_seconds: parse_env_i64( + ENV_CAPTCHA_TTL_SECONDS, + DEFAULT_CAPTCHA_TTL_SECONDS, + 30, + 24 * 60 * 60, + ), + } +} + +fn captcha_store() -> &'static Mutex> { + static STORE: OnceLock>> = OnceLock::new(); + STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn cleanup_expired_captcha_entries(store: &mut HashMap, now: DateTime) { + let expired = store + .iter() + .filter_map(|(token, entry)| (entry.expires_at <= now).then_some(token.clone())) + .collect::>(); + + for token in expired { + store.remove(&token); + } +} + +pub fn normalize_matcher_type(value: &str) -> Option<&'static str> { + match value.trim().to_ascii_lowercase().as_str() { + MATCHER_TYPE_IP => Some(MATCHER_TYPE_IP), + MATCHER_TYPE_EMAIL => Some(MATCHER_TYPE_EMAIL), + MATCHER_TYPE_USER_AGENT | "ua" | "useragent" => Some(MATCHER_TYPE_USER_AGENT), + _ => None, + } +} + +pub fn normalize_matcher_value(matcher_type: &str, raw_value: &str) -> Option { + let normalized_type = normalize_matcher_type(matcher_type)?; + + match normalized_type { + MATCHER_TYPE_IP => normalize_ip(Some(raw_value)), + MATCHER_TYPE_EMAIL => normalize_email(Some(raw_value)), + MATCHER_TYPE_USER_AGENT => normalize_user_agent(Some(raw_value)), + _ => None, + } +} + +pub fn create_captcha_challenge(client_ip: Option<&str>) -> Result { + let config = load_config(); + let seed = Uuid::new_v4().as_u128(); + let left = ((seed % 9) + 1) as i64; + let right = (((seed / 11) % 9) + 1) as i64; + let use_subtract = seed % 2 == 0 && left > right; + let (question, answer) = if use_subtract { + ( + format!("{} - {} = ?", left, right), + (left - right).to_string(), + ) + } else { + ( + format!("{} + {} = ?", left, right), + (left + right).to_string(), + ) + }; + + let token = Uuid::new_v4().to_string(); + let now = Utc::now(); + let expires_at = now + Duration::seconds(config.captcha_ttl_seconds); + let ip_address = normalize_ip(client_ip); + + let mut store = captcha_store() + .lock() + .map_err(|_| Error::InternalServerError)?; + cleanup_expired_captcha_entries(&mut store, now); + store.insert( + token.clone(), + CaptchaEntry { + answer, + expires_at, + ip_address, + }, + ); + + Ok(CommentCaptchaChallenge { + token, + question, + expires_in_seconds: config.captcha_ttl_seconds, + }) +} + +pub fn verify_captcha_solution( + captcha_token: Option<&str>, + captcha_answer: Option<&str>, + client_ip: Option<&str>, +) -> Result<()> { + let token = trim_to_option(captcha_token) + .ok_or_else(|| Error::BadRequest("请先完成验证码".to_string()))?; + let answer = trim_to_option(captcha_answer) + .ok_or_else(|| Error::BadRequest("请填写验证码答案".to_string()))?; + + let now = Utc::now(); + let normalized_ip = normalize_ip(client_ip); + + let mut store = captcha_store() + .lock() + .map_err(|_| Error::InternalServerError)?; + cleanup_expired_captcha_entries(&mut store, now); + + let Some(entry) = store.remove(&token) else { + return Err(Error::BadRequest("验证码已失效,请刷新后重试".to_string())); + }; + + if entry.expires_at <= now { + return Err(Error::BadRequest("验证码已过期,请刷新后重试".to_string())); + } + + if entry + .ip_address + .as_deref() + .zip(normalized_ip.as_deref()) + .is_some_and(|(expected, current)| expected != current) + { + return Err(Error::BadRequest( + "验证码校验失败,请刷新后重试".to_string(), + )); + } + + if entry.answer.trim() != answer.trim() { + return Err(Error::BadRequest("验证码答案错误".to_string())); + } + + Ok(()) +} + +async fn check_blacklist(ctx: &AppContext, input: &CommentGuardInput<'_>) -> Result<()> { + let now = Utc::now(); + let candidates = [ + (MATCHER_TYPE_IP, normalize_ip(input.ip_address)), + (MATCHER_TYPE_EMAIL, normalize_email(input.email)), + ( + MATCHER_TYPE_USER_AGENT, + normalize_user_agent(input.user_agent), + ), + ]; + + for (matcher_type, matcher_value) in candidates { + let Some(matcher_value) = matcher_value else { + continue; + }; + + let matched = comment_blacklist::Entity::find() + .filter(comment_blacklist::Column::MatcherType.eq(matcher_type)) + .filter(comment_blacklist::Column::MatcherValue.eq(&matcher_value)) + .filter( + Condition::any() + .add(comment_blacklist::Column::Active.is_null()) + .add(comment_blacklist::Column::Active.eq(true)), + ) + .filter( + Condition::any() + .add(comment_blacklist::Column::ExpiresAt.is_null()) + .add(comment_blacklist::Column::ExpiresAt.gt(now)), + ) + .one(&ctx.db) + .await?; + + if matched.is_some() { + return Err(Error::BadRequest("评论请求已被拦截".to_string())); + } + } + + Ok(()) +} + +async fn check_rate_limit(ctx: &AppContext, input: &CommentGuardInput<'_>) -> Result<()> { + let config = load_config(); + let Some(ip_address) = normalize_ip(input.ip_address) else { + return Ok(()); + }; + + let now = Utc::now(); + let since = now - Duration::seconds(config.rate_limit_window_seconds); + + let count = comments::Entity::find() + .filter(comments::Column::IpAddress.eq(&ip_address)) + .filter(comments::Column::CreatedAt.gte(since)) + .count(&ctx.db) + .await?; + + if count >= config.rate_limit_max_per_window { + return Err(Error::BadRequest("评论过于频繁,请稍后再试".to_string())); + } + + if config.min_interval_seconds <= 0 { + return Ok(()); + } + + if let Some(last_comment) = comments::Entity::find() + .filter(comments::Column::IpAddress.eq(&ip_address)) + .order_by_desc(comments::Column::CreatedAt) + .one(&ctx.db) + .await? + { + let last_created_at = DateTime::::from(last_comment.created_at); + let elapsed = now.signed_duration_since(last_created_at).num_seconds(); + if elapsed < config.min_interval_seconds { + return Err(Error::BadRequest("提交过快,请稍后再试".to_string())); + } + } + + Ok(()) +} + +fn contains_blocked_keyword(input: &CommentGuardInput<'_>) -> Option { + let config = load_config(); + if config.blocked_keywords.is_empty() { + return None; + } + + let mut merged = String::new(); + for value in [input.author, input.email, input.content] { + if let Some(value) = value { + merged.push_str(value); + merged.push('\n'); + } + } + let lower = merged.to_lowercase(); + + config + .blocked_keywords + .into_iter() + .find(|keyword| lower.contains(keyword)) +} + +pub async fn enforce_comment_guard(ctx: &AppContext, input: &CommentGuardInput<'_>) -> Result<()> { + if trim_to_option(input.honeypot_website).is_some() { + return Err(Error::BadRequest("提交未通过校验".to_string())); + } + + verify_captcha_solution(input.captcha_token, input.captcha_answer, input.ip_address)?; + + if contains_blocked_keyword(input).is_some() { + return Err(Error::BadRequest("评论内容包含敏感关键词".to_string())); + } + + check_blacklist(ctx, input).await?; + check_rate_limit(ctx, input).await?; + + Ok(()) +} diff --git a/backend/src/services/content.rs b/backend/src/services/content.rs index 54a4955..d2b2e14 100644 --- a/backend/src/services/content.rs +++ b/backend/src/services/content.rs @@ -1,28 +1,55 @@ +use chrono::{DateTime, FixedOffset, NaiveDate, TimeZone, Utc}; use loco_rs::prelude::*; use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, + ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter, + QueryOrder, Set, }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; -use crate::models::_entities::{categories, posts, tags}; +use crate::models::_entities::{categories, comments, posts, tags}; pub const MARKDOWN_POSTS_DIR: &str = "content/posts"; const FIXTURE_POSTS_FILE: &str = "src/fixtures/posts.yaml"; +pub const POST_STATUS_DRAFT: &str = "draft"; +pub const POST_STATUS_PUBLISHED: &str = "published"; +pub const POST_STATUS_OFFLINE: &str = "offline"; +pub const POST_VISIBILITY_PUBLIC: &str = "public"; +pub const POST_VISIBILITY_UNLISTED: &str = "unlisted"; +pub const POST_VISIBILITY_PRIVATE: &str = "private"; #[derive(Debug, Clone, Default, Deserialize, Serialize)] struct MarkdownFrontmatter { title: Option, slug: Option, description: Option, - category: Option, + #[serde( + default, + alias = "category", + alias = "categories", + deserialize_with = "deserialize_optional_string_list" + )] + categories: Option>, + #[serde(default, deserialize_with = "deserialize_optional_string_list")] tags: Option>, post_type: Option, image: Option, + images: Option>, pinned: Option, published: Option, + draft: Option, + status: Option, + visibility: Option, + publish_at: Option, + unpublish_at: Option, + canonical_url: Option, + noindex: Option, + og_image: Option, + #[serde(default, deserialize_with = "deserialize_optional_string_list")] + redirect_from: Option>, + redirect_to: Option, } #[derive(Debug, Clone, Serialize)] @@ -35,8 +62,17 @@ pub struct MarkdownPost { pub tags: Vec, pub post_type: String, pub image: Option, + pub images: Vec, pub pinned: bool, - pub published: bool, + pub status: String, + pub visibility: String, + pub publish_at: Option, + pub unpublish_at: Option, + pub canonical_url: Option, + pub noindex: bool, + pub og_image: Option, + pub redirect_from: Vec, + pub redirect_to: Option, pub file_path: String, } @@ -50,8 +86,17 @@ pub struct MarkdownPostDraft { pub tags: Vec, pub post_type: String, pub image: Option, + pub images: Vec, pub pinned: bool, - pub published: bool, + pub status: String, + pub visibility: String, + pub publish_at: Option, + pub unpublish_at: Option, + pub canonical_url: Option, + pub noindex: bool, + pub og_image: Option, + pub redirect_from: Vec, + pub redirect_to: Option, } #[derive(Debug, Clone)] @@ -103,13 +148,212 @@ fn trim_to_option(input: Option) -> Option { }) } +fn normalize_string_list(values: Option>) -> Vec { + values + .unwrap_or_default() + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect() +} + +fn yaml_scalar(value: &str) -> String { + serde_yaml::to_string(value) + .unwrap_or_else(|_| format!("{value:?}")) + .trim() + .to_string() +} + +fn normalize_redirect_list(values: Option>) -> Vec { + let mut seen = std::collections::HashSet::new(); + + normalize_string_list(values) + .into_iter() + .map(|item| item.trim_matches('/').to_string()) + .filter(|item| !item.is_empty()) + .filter(|item| seen.insert(item.to_lowercase())) + .collect() +} + +fn normalize_url_like(value: Option) -> Option { + trim_to_option(value).map(|item| item.trim_end_matches('/').to_string()) +} + +pub fn normalize_post_status(value: Option<&str>) -> String { + match value + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + POST_STATUS_DRAFT => POST_STATUS_DRAFT.to_string(), + POST_STATUS_OFFLINE => POST_STATUS_OFFLINE.to_string(), + _ => POST_STATUS_PUBLISHED.to_string(), + } +} + +pub fn normalize_post_visibility(value: Option<&str>) -> String { + match value + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + POST_VISIBILITY_UNLISTED => POST_VISIBILITY_UNLISTED.to_string(), + POST_VISIBILITY_PRIVATE => POST_VISIBILITY_PRIVATE.to_string(), + _ => POST_VISIBILITY_PUBLIC.to_string(), + } +} + +fn parse_frontmatter_datetime(value: Option) -> Option> { + let raw = trim_to_option(value)?; + + if let Ok(parsed) = DateTime::parse_from_rfc3339(&raw) { + return Some(parsed); + } + + if let Ok(date_only) = NaiveDate::parse_from_str(&raw, "%Y-%m-%d") { + let naive = date_only.and_hms_opt(0, 0, 0)?; + return FixedOffset::east_opt(0)?.from_local_datetime(&naive).single(); + } + + None +} + +pub fn format_frontmatter_datetime(value: Option>) -> Option { + value.map(|item| item.with_timezone(&Utc).to_rfc3339()) +} + +fn resolve_post_status(frontmatter: &MarkdownFrontmatter) -> String { + if let Some(status) = trim_to_option(frontmatter.status.clone()) { + return normalize_post_status(Some(&status)); + } + + if frontmatter.draft.unwrap_or(false) { + POST_STATUS_DRAFT.to_string() + } else if frontmatter.published.unwrap_or(true) { + POST_STATUS_PUBLISHED.to_string() + } else { + POST_STATUS_DRAFT.to_string() + } +} + +pub fn effective_post_state( + status: &str, + publish_at: Option>, + unpublish_at: Option>, + now: DateTime, +) -> String { + let normalized_status = normalize_post_status(Some(status)); + + if normalized_status == POST_STATUS_DRAFT { + return POST_STATUS_DRAFT.to_string(); + } + + if normalized_status == POST_STATUS_OFFLINE { + return POST_STATUS_OFFLINE.to_string(); + } + + if publish_at.map(|value| value > now).unwrap_or(false) { + return "scheduled".to_string(); + } + + if unpublish_at.map(|value| value <= now).unwrap_or(false) { + return "expired".to_string(); + } + + POST_STATUS_PUBLISHED.to_string() +} + +pub fn post_redirects_from_json(value: &Option) -> Vec { + value + .as_ref() + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| item.as_str().map(ToString::to_string)) + .map(|item| item.trim_matches('/').to_string()) + .filter(|item| !item.is_empty()) + .collect() +} + +pub fn is_post_listed_publicly(post: &posts::Model, now: DateTime) -> bool { + effective_post_state( + post.status.as_deref().unwrap_or(POST_STATUS_PUBLISHED), + post.publish_at, + post.unpublish_at, + now, + ) == POST_STATUS_PUBLISHED + && normalize_post_visibility(post.visibility.as_deref()) == POST_VISIBILITY_PUBLIC +} + +pub fn is_post_publicly_accessible(post: &posts::Model, now: DateTime) -> bool { + effective_post_state( + post.status.as_deref().unwrap_or(POST_STATUS_PUBLISHED), + post.publish_at, + post.unpublish_at, + now, + ) == POST_STATUS_PUBLISHED + && normalize_post_visibility(post.visibility.as_deref()) != POST_VISIBILITY_PRIVATE +} + +fn split_inline_list(value: &str) -> Vec { + value + .split([',', ',']) + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect() +} + +fn deserialize_optional_string_list<'de, D>( + deserializer: D, +) -> std::result::Result>, D::Error> +where + D: Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + + match raw { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::String(value)) => { + let items = split_inline_list(&value); + if items.is_empty() && !value.trim().is_empty() { + Ok(Some(vec![value.trim().to_string()])) + } else if items.is_empty() { + Ok(None) + } else { + Ok(Some(items)) + } + } + Some(serde_yaml::Value::Sequence(items)) => Ok(Some( + items + .into_iter() + .filter_map(|item| match item { + serde_yaml::Value::String(value) => { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + } + serde_yaml::Value::Number(value) => Some(value.to_string()), + _ => None, + }) + .collect(), + )), + Some(other) => Err(serde::de::Error::custom(format!( + "unsupported frontmatter list value: {other:?}" + ))), + } +} + fn slugify(value: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for ch in value.trim().chars() { - if ch.is_ascii_alphanumeric() { - slug.push(ch.to_ascii_lowercase()); + if ch.is_alphanumeric() { + for lower in ch.to_lowercase() { + slug.push(lower); + } last_was_dash = false; } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash { slug.push('-'); @@ -120,6 +364,19 @@ fn slugify(value: &str) -> String { slug.trim_matches('-').to_string() } +fn normalized_match_key(value: &str) -> String { + value.trim().to_lowercase() +} + +fn same_text(left: &str, right: &str) -> bool { + normalized_match_key(left) == normalized_match_key(right) +} + +fn text_matches_any(value: &str, keys: &[String]) -> bool { + let current = normalized_match_key(value); + !current.is_empty() && keys.iter().any(|key| current == *key) +} + fn excerpt_from_content(content: &str) -> Option { let mut in_code_block = false; @@ -135,7 +392,11 @@ fn excerpt_from_content(content: &str) -> Option { } let excerpt = trimmed.chars().take(180).collect::(); - return if excerpt.is_empty() { None } else { Some(excerpt) }; + return if excerpt.is_empty() { + None + } else { + Some(excerpt) + }; } None @@ -181,17 +442,21 @@ fn parse_markdown_post(path: &Path) -> Result { parse_markdown_source(&file_stem, &raw, &path.to_string_lossy()) } -fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result { +pub fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result { let (frontmatter, content) = split_frontmatter(raw)?; let slug = trim_to_option(frontmatter.slug.clone()).unwrap_or_else(|| file_stem.to_string()); let title = trim_to_option(frontmatter.title.clone()) .or_else(|| title_from_content(&content)) .unwrap_or_else(|| slug.clone()); - let description = trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content)); - let category = trim_to_option(frontmatter.category.clone()); + let description = + trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content)); + let category = normalize_string_list(frontmatter.categories.clone()) + .into_iter() + .next(); let tags = frontmatter .tags + .clone() .unwrap_or_default() .into_iter() .map(|item| item.trim().to_string()) @@ -205,49 +470,95 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result< content: content.trim_start_matches('\n').to_string(), category, tags, - post_type: trim_to_option(frontmatter.post_type.clone()).unwrap_or_else(|| "article".to_string()), + post_type: trim_to_option(frontmatter.post_type.clone()) + .unwrap_or_else(|| "article".to_string()), image: trim_to_option(frontmatter.image.clone()), + images: normalize_string_list(frontmatter.images.clone()), pinned: frontmatter.pinned.unwrap_or(false), - published: frontmatter.published.unwrap_or(true), + status: resolve_post_status(&frontmatter), + visibility: normalize_post_visibility(frontmatter.visibility.as_deref()), + publish_at: format_frontmatter_datetime(parse_frontmatter_datetime( + frontmatter.publish_at.clone(), + )), + unpublish_at: format_frontmatter_datetime(parse_frontmatter_datetime( + frontmatter.unpublish_at.clone(), + )), + canonical_url: normalize_url_like(frontmatter.canonical_url.clone()), + noindex: frontmatter.noindex.unwrap_or(false), + og_image: normalize_url_like(frontmatter.og_image.clone()), + redirect_from: normalize_redirect_list(frontmatter.redirect_from.clone()), + redirect_to: trim_to_option(frontmatter.redirect_to.clone()) + .map(|item| item.trim_matches('/').to_string()), file_path: file_path.to_string(), }) } -fn build_markdown_document(post: &MarkdownPost) -> String { +pub fn build_markdown_document(post: &MarkdownPost) -> String { let mut lines = vec![ "---".to_string(), - format!("title: {}", serde_yaml::to_string(&post.title).unwrap_or_else(|_| format!("{:?}", post.title)).trim()), - format!("slug: {}", post.slug), + format!("title: {}", yaml_scalar(&post.title)), + format!("slug: {}", yaml_scalar(&post.slug)), ]; if let Some(description) = &post.description { - lines.push(format!( - "description: {}", - serde_yaml::to_string(description) - .unwrap_or_else(|_| format!("{description:?}")) - .trim() - )); + lines.push(format!("description: {}", yaml_scalar(description))); } if let Some(category) = &post.category { - lines.push(format!("category: {}", category)); + lines.push(format!("category: {}", yaml_scalar(category))); } - lines.push(format!("post_type: {}", post.post_type)); + lines.push(format!("post_type: {}", yaml_scalar(&post.post_type))); lines.push(format!("pinned: {}", post.pinned)); - lines.push(format!("published: {}", post.published)); + lines.push(format!("status: {}", yaml_scalar(&post.status))); + lines.push(format!("visibility: {}", yaml_scalar(&post.visibility))); + lines.push(format!("noindex: {}", post.noindex)); + + if let Some(publish_at) = &post.publish_at { + lines.push(format!("publish_at: {}", yaml_scalar(publish_at))); + } + + if let Some(unpublish_at) = &post.unpublish_at { + lines.push(format!("unpublish_at: {}", yaml_scalar(unpublish_at))); + } if let Some(image) = &post.image { - lines.push(format!("image: {}", image)); + lines.push(format!("image: {}", yaml_scalar(image))); + } + + if !post.images.is_empty() { + lines.push("images:".to_string()); + for image in &post.images { + lines.push(format!(" - {}", yaml_scalar(image))); + } } if !post.tags.is_empty() { lines.push("tags:".to_string()); for tag in &post.tags { - lines.push(format!(" - {}", tag)); + lines.push(format!(" - {}", yaml_scalar(tag))); } } + if let Some(canonical_url) = &post.canonical_url { + lines.push(format!("canonical_url: {}", yaml_scalar(canonical_url))); + } + + if let Some(og_image) = &post.og_image { + lines.push(format!("og_image: {}", yaml_scalar(og_image))); + } + + if !post.redirect_from.is_empty() { + lines.push("redirect_from:".to_string()); + for redirect in &post.redirect_from { + lines.push(format!(" - {}", yaml_scalar(redirect))); + } + } + + if let Some(redirect_to) = &post.redirect_to { + lines.push(format!("redirect_to: {}", yaml_scalar(redirect_to))); + } + lines.push("---".to_string()); lines.push(String::new()); lines.push(post.content.trim().to_string()); @@ -282,12 +593,31 @@ fn ensure_markdown_posts_bootstrapped() -> Result<()> { tags: fixture.tags.unwrap_or_default(), post_type: "article".to_string(), image: None, + images: Vec::new(), pinned: fixture.pinned.unwrap_or(false), - published: fixture.published.unwrap_or(true), - file_path: markdown_post_path(&fixture.slug).to_string_lossy().to_string(), + status: if fixture.published.unwrap_or(true) { + POST_STATUS_PUBLISHED.to_string() + } else { + POST_STATUS_DRAFT.to_string() + }, + visibility: POST_VISIBILITY_PUBLIC.to_string(), + publish_at: None, + unpublish_at: None, + canonical_url: None, + noindex: false, + og_image: None, + redirect_from: Vec::new(), + redirect_to: None, + file_path: markdown_post_path(&fixture.slug) + .to_string_lossy() + .to_string(), }; - fs::write(markdown_post_path(&fixture.slug), build_markdown_document(&post)).map_err(io_error)?; + fs::write( + markdown_post_path(&fixture.slug), + build_markdown_document(&post), + ) + .map_err(io_error)?; } Ok(()) @@ -312,14 +642,19 @@ async fn sync_tags_from_posts(ctx: &AppContext, posts: &[MarkdownPost]) -> Resul for post in posts { for tag_name in &post.tags { let slug = slugify(tag_name); + let trimmed = tag_name.trim(); let existing = tags::Entity::find() - .filter(tags::Column::Slug.eq(&slug)) + .filter( + Condition::any() + .add(tags::Column::Slug.eq(&slug)) + .add(tags::Column::Name.eq(trimmed)), + ) .one(&ctx.db) .await?; if existing.is_none() { let item = tags::ActiveModel { - name: Set(Some(tag_name.clone())), + name: Set(Some(trimmed.to_string())), slug: Set(slug), ..Default::default() }; @@ -339,12 +674,21 @@ async fn ensure_category(ctx: &AppContext, raw_name: &str) -> Result Result Result Result<()> { + fs::write( + markdown_post_path(&post.slug), + build_markdown_document(post), + ) + .map_err(io_error) +} + +pub fn rewrite_category_references( + current_name: Option<&str>, + current_slug: &str, + next_name: Option<&str>, +) -> Result { + ensure_markdown_posts_bootstrapped()?; + + let mut match_keys = Vec::new(); + if let Some(name) = current_name { + let normalized = normalized_match_key(name); + if !normalized.is_empty() { + match_keys.push(normalized); + } + } + + let normalized_slug = normalized_match_key(current_slug); + if !normalized_slug.is_empty() { + match_keys.push(normalized_slug); + } + + if match_keys.is_empty() { + return Ok(0); + } + + let next_category = next_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut changed = 0_usize; + let mut posts = load_markdown_posts_from_disk()?; + + for post in &mut posts { + let Some(category) = post.category.as_deref() else { + continue; + }; + + if !text_matches_any(category, &match_keys) { + continue; + } + + match &next_category { + Some(updated_name) if same_text(category, updated_name) => {} + Some(updated_name) => { + post.category = Some(updated_name.clone()); + write_markdown_post_to_disk(post)?; + changed += 1; + } + None => { + post.category = None; + write_markdown_post_to_disk(post)?; + changed += 1; + } + } + } + + Ok(changed) +} + +pub fn rewrite_tag_references( + current_name: Option<&str>, + current_slug: &str, + next_name: Option<&str>, +) -> Result { + ensure_markdown_posts_bootstrapped()?; + + let mut match_keys = Vec::new(); + if let Some(name) = current_name { + let normalized = normalized_match_key(name); + if !normalized.is_empty() { + match_keys.push(normalized); + } + } + + let normalized_slug = normalized_match_key(current_slug); + if !normalized_slug.is_empty() { + match_keys.push(normalized_slug); + } + + if match_keys.is_empty() { + return Ok(0); + } + + let next_tag = next_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut changed = 0_usize; + let mut posts = load_markdown_posts_from_disk()?; + + for post in &mut posts { + let mut updated_tags = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut post_changed = false; + + for tag in &post.tags { + if text_matches_any(tag, &match_keys) { + post_changed = true; + if let Some(next_tag_name) = &next_tag { + let normalized = normalized_match_key(next_tag_name); + if seen.insert(normalized) { + updated_tags.push(next_tag_name.clone()); + } + } + continue; + } + + let normalized = normalized_match_key(tag); + if seen.insert(normalized) { + updated_tags.push(tag.clone()); + } + } + + if post_changed { + post.tags = updated_tags; + write_markdown_post_to_disk(post)?; + changed += 1; + } + } + + Ok(changed) +} + async fn dedupe_tags(ctx: &AppContext) -> Result<()> { let existing_tags = tags::Entity::find() .order_by_asc(tags::Column::Id) @@ -425,10 +908,7 @@ async fn dedupe_tags(ctx: &AppContext) -> Result<()> { for tag in existing_tags { let key = if tag.slug.trim().is_empty() { - tag.name - .as_deref() - .map(slugify) - .unwrap_or_default() + tag.name.as_deref().map(slugify).unwrap_or_default() } else { slugify(&tag.slug) }; @@ -453,11 +933,7 @@ async fn dedupe_categories(ctx: &AppContext) -> Result<()> { for category in existing_categories { let key = if category.slug.trim().is_empty() { - category - .name - .as_deref() - .map(slugify) - .unwrap_or_default() + category.name.as_deref().map(slugify).unwrap_or_default() } else { slugify(&category.slug) }; @@ -474,6 +950,28 @@ async fn dedupe_categories(ctx: &AppContext) -> Result<()> { pub async fn sync_markdown_posts(ctx: &AppContext) -> Result> { let markdown_posts = load_markdown_posts_from_disk()?; + let markdown_slugs = markdown_posts + .iter() + .map(|post| post.slug.clone()) + .collect::>(); + let existing_posts = posts::Entity::find().all(&ctx.db).await?; + + for stale_post in existing_posts + .into_iter() + .filter(|post| !markdown_slugs.contains(&post.slug)) + { + let stale_slug = stale_post.slug.clone(); + let related_comments = comments::Entity::find() + .filter(comments::Column::PostSlug.eq(&stale_slug)) + .all(&ctx.db) + .await?; + + for comment in related_comments { + let _ = comment.delete(&ctx.db).await; + } + + let _ = stale_post.delete(&ctx.db).await; + } for post in &markdown_posts { let canonical_category = match post.category.as_deref() { @@ -506,7 +1004,39 @@ pub async fn sync_markdown_posts(ctx: &AppContext) -> Result> }); model.post_type = Set(Some(post.post_type.clone())); model.image = Set(post.image.clone()); + model.images = Set(if post.images.is_empty() { + None + } else { + Some(Value::Array( + post.images + .iter() + .cloned() + .map(Value::String) + .collect::>(), + )) + }); model.pinned = Set(Some(post.pinned)); + model.status = Set(Some(normalize_post_status(Some(&post.status)))); + model.visibility = Set(Some(normalize_post_visibility(Some(&post.visibility)))); + model.publish_at = Set(parse_frontmatter_datetime(post.publish_at.clone())); + model.unpublish_at = Set(parse_frontmatter_datetime(post.unpublish_at.clone())); + model.canonical_url = Set(normalize_url_like(post.canonical_url.clone())); + model.noindex = Set(Some(post.noindex)); + model.og_image = Set(normalize_url_like(post.og_image.clone())); + model.redirect_from = Set(if post.redirect_from.is_empty() { + None + } else { + Some(Value::Array( + post.redirect_from + .iter() + .cloned() + .map(Value::String) + .collect::>(), + )) + }); + model.redirect_to = Set( + trim_to_option(post.redirect_to.clone()).map(|item| item.trim_matches('/').to_string()), + ); if has_existing { let _ = model.update(&ctx.db).await; @@ -545,6 +1075,18 @@ pub async fn write_markdown_document( Ok(updated) } +pub async fn delete_markdown_post(ctx: &AppContext, slug: &str) -> Result<()> { + ensure_markdown_posts_bootstrapped()?; + let path = markdown_post_path(slug); + if !path.exists() { + return Err(Error::NotFound); + } + + fs::remove_file(&path).map_err(io_error)?; + sync_markdown_posts(ctx).await?; + Ok(()) +} + pub async fn create_markdown_post( ctx: &AppContext, draft: MarkdownPostDraft, @@ -589,14 +1131,31 @@ pub async fn create_markdown_post( } }, image: trim_to_option(draft.image), + images: normalize_string_list(Some(draft.images)), pinned: draft.pinned, - published: draft.published, + status: normalize_post_status(Some(&draft.status)), + visibility: normalize_post_visibility(Some(&draft.visibility)), + publish_at: format_frontmatter_datetime(parse_frontmatter_datetime(draft.publish_at)), + unpublish_at: format_frontmatter_datetime(parse_frontmatter_datetime(draft.unpublish_at)), + canonical_url: normalize_url_like(draft.canonical_url), + noindex: draft.noindex, + og_image: normalize_url_like(draft.og_image), + redirect_from: normalize_redirect_list(Some(draft.redirect_from)), + redirect_to: trim_to_option(draft.redirect_to) + .map(|item| item.trim_matches('/').to_string()), file_path: markdown_post_path(&slug).to_string_lossy().to_string(), }; - fs::write(markdown_post_path(&slug), build_markdown_document(&post)).map_err(io_error)?; + let path = markdown_post_path(&slug); + if path.exists() { + return Err(Error::BadRequest(format!( + "markdown post already exists for slug: {slug}" + ))); + } + + fs::write(&path, build_markdown_document(&post)).map_err(io_error)?; sync_markdown_posts(ctx).await?; - parse_markdown_post(&markdown_post_path(&slug)) + parse_markdown_post(&path) } pub async fn import_markdown_documents( @@ -635,7 +1194,8 @@ pub async fn import_markdown_documents( continue; } - fs::write(markdown_post_path(&slug), normalize_newlines(&file.content)).map_err(io_error)?; + fs::write(markdown_post_path(&slug), normalize_newlines(&file.content)) + .map_err(io_error)?; imported_slugs.push(slug); } diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index ee90b90..e2787f0 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1 +1,10 @@ +pub mod admin_audit; +pub mod abuse_guard; +pub mod ai; +pub mod analytics; +pub mod comment_guard; pub mod content; +pub mod notifications; +pub mod post_revisions; +pub mod storage; +pub mod subscriptions; diff --git a/backend/src/services/notifications.rs b/backend/src/services/notifications.rs new file mode 100644 index 0000000..4b48e43 --- /dev/null +++ b/backend/src/services/notifications.rs @@ -0,0 +1,164 @@ +use loco_rs::prelude::*; +use crate::{ + controllers::site_settings, + models::_entities::{comments, friend_links}, + services::subscriptions, +}; + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn excerpt(value: Option<&str>, limit: usize) -> Option { + let flattened = value? + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string(); + + if flattened.is_empty() { + return None; + } + + let mut shortened = flattened.chars().take(limit).collect::(); + if flattened.chars().count() > limit { + shortened.push_str("..."); + } + Some(shortened) +} + +pub async fn notify_new_comment(ctx: &AppContext, item: &comments::Model) { + let settings = match site_settings::load_current(ctx).await { + Ok(settings) => settings, + Err(error) => { + tracing::warn!("failed to load site settings before comment notification: {error}"); + return; + } + }; + + let payload = serde_json::json!({ + "event_type": subscriptions::EVENT_COMMENT_CREATED, + "id": item.id, + "post_slug": item.post_slug, + "author": item.author, + "email": item.email, + "scope": item.scope, + "paragraph_key": item.paragraph_key, + "approved": item.approved.unwrap_or(false), + "excerpt": excerpt(item.content.as_deref(), 200), + "created_at": item.created_at.to_rfc3339(), + }); + let text = format!( + "收到一条新的评论。\n\n文章:{}\n作者:{}\n范围:{}\n状态:{}\n摘要:{}", + item.post_slug.clone().unwrap_or_else(|| "未知文章".to_string()), + item.author.clone().unwrap_or_else(|| "匿名".to_string()), + item.scope, + if item.approved.unwrap_or(false) { "已通过" } else { "待审核" }, + excerpt(item.content.as_deref(), 200).unwrap_or_else(|| "无".to_string()), + ); + + if let Err(error) = subscriptions::queue_event_for_active_subscriptions( + ctx, + subscriptions::EVENT_COMMENT_CREATED, + "新评论通知", + &text, + payload.clone(), + trim_to_option(settings.site_name.clone()), + trim_to_option(settings.site_url.clone()), + ) + .await + { + tracing::warn!("failed to queue comment subscription notification: {error}"); + } + + if settings.notification_comment_enabled.unwrap_or(false) { + if let Some(target) = trim_to_option(settings.notification_webhook_url.clone()) { + if let Err(error) = subscriptions::queue_direct_notification( + ctx, + subscriptions::CHANNEL_WEBHOOK, + &target, + subscriptions::EVENT_COMMENT_CREATED, + "新评论通知", + &text, + payload, + trim_to_option(settings.site_name), + trim_to_option(settings.site_url), + ) + .await + { + tracing::warn!("failed to queue legacy comment webhook notification: {error}"); + } + } + } +} + +pub async fn notify_new_friend_link(ctx: &AppContext, item: &friend_links::Model) { + let settings = match site_settings::load_current(ctx).await { + Ok(settings) => settings, + Err(error) => { + tracing::warn!("failed to load site settings before friend-link notification: {error}"); + return; + } + }; + + let payload = serde_json::json!({ + "event_type": subscriptions::EVENT_FRIEND_LINK_CREATED, + "id": item.id, + "site_name": item.site_name, + "site_url": item.site_url, + "category": item.category, + "status": item.status, + "description": item.description, + "created_at": item.created_at.to_rfc3339(), + }); + let text = format!( + "收到新的友链申请。\n\n站点:{}\n链接:{}\n分类:{}\n状态:{}\n描述:{}", + item.site_name.clone().unwrap_or_else(|| "未命名站点".to_string()), + item.site_url, + item.category.clone().unwrap_or_else(|| "未分类".to_string()), + item.status.clone().unwrap_or_else(|| "pending".to_string()), + item.description.clone().unwrap_or_else(|| "无".to_string()), + ); + + if let Err(error) = subscriptions::queue_event_for_active_subscriptions( + ctx, + subscriptions::EVENT_FRIEND_LINK_CREATED, + "新友链申请通知", + &text, + payload.clone(), + trim_to_option(settings.site_name.clone()), + trim_to_option(settings.site_url.clone()), + ) + .await + { + tracing::warn!("failed to queue friend-link subscription notification: {error}"); + } + + if settings.notification_friend_link_enabled.unwrap_or(false) { + if let Some(target) = trim_to_option(settings.notification_webhook_url.clone()) { + if let Err(error) = subscriptions::queue_direct_notification( + ctx, + subscriptions::CHANNEL_WEBHOOK, + &target, + subscriptions::EVENT_FRIEND_LINK_CREATED, + "新友链申请通知", + &text, + payload, + trim_to_option(settings.site_name), + trim_to_option(settings.site_url), + ) + .await + { + tracing::warn!("failed to queue legacy friend-link webhook notification: {error}"); + } + } + } +} diff --git a/backend/src/services/post_revisions.rs b/backend/src/services/post_revisions.rs new file mode 100644 index 0000000..3941e74 --- /dev/null +++ b/backend/src/services/post_revisions.rs @@ -0,0 +1,247 @@ +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder, QuerySelect, Set, +}; +use std::fs; + +use crate::{ + controllers::admin::AdminIdentity, + models::_entities::{post_revisions, posts}, + services::content, +}; + +#[derive(Clone, Copy, Debug)] +pub enum RestoreMode { + Full, + Markdown, + Metadata, +} + +impl RestoreMode { + pub fn parse(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "markdown" | "content" | "body" => Self::Markdown, + "metadata" | "frontmatter" => Self::Metadata, + _ => Self::Full, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Full => "full", + Self::Markdown => "markdown", + Self::Metadata => "metadata", + } + } +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn title_from_markdown(markdown: &str, slug: &str) -> Option { + let normalized = markdown.replace("\r\n", "\n"); + if let Some(frontmatter) = normalized + .strip_prefix("---\n") + .and_then(|rest| rest.split_once("\n---\n").map(|(frontmatter, _)| frontmatter)) + { + for line in frontmatter.lines() { + let trimmed = line.trim(); + if let Some(raw) = trimmed.strip_prefix("title:") { + let title = raw.trim().trim_matches('"').trim_matches('\'').trim(); + if !title.is_empty() { + return Some(title.to_string()); + } + } + } + } + + normalized.lines().find_map(|line| { + line.trim() + .strip_prefix("# ") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .or_else(|| trim_to_option(Some(slug.to_string()))) +} + +async fn lookup_post_title(ctx: &AppContext, slug: &str) -> Option { + posts::Entity::find() + .filter(posts::Column::Slug.eq(slug)) + .one(&ctx.db) + .await + .ok() + .flatten() + .and_then(|item| item.title) + .and_then(|value| trim_to_option(Some(value))) +} + +pub async fn capture_snapshot_from_markdown( + ctx: &AppContext, + actor: Option<&AdminIdentity>, + slug: &str, + markdown: &str, + operation: &str, + reason: Option<&str>, + metadata: Option, +) -> Result { + let post_title = lookup_post_title(ctx, slug) + .await + .or_else(|| title_from_markdown(markdown, slug)); + + post_revisions::ActiveModel { + post_slug: Set(slug.to_string()), + post_title: Set(post_title), + operation: Set(operation.to_string()), + revision_reason: Set(reason.map(ToString::to_string)), + actor_username: Set(actor.map(|item| item.username.clone())), + actor_email: Set(actor.and_then(|item| item.email.clone())), + actor_source: Set(actor.map(|item| item.source.clone())), + markdown: Set(Some(markdown.replace("\r\n", "\n"))), + metadata: Set(metadata), + ..Default::default() + } + .insert(&ctx.db) + .await + .map_err(Into::into) +} + +pub async fn capture_current_snapshot( + ctx: &AppContext, + actor: Option<&AdminIdentity>, + slug: &str, + operation: &str, + reason: Option<&str>, + metadata: Option, +) -> Result> { + let Ok((_path, markdown)) = content::read_markdown_document(slug) else { + return Ok(None); + }; + + capture_snapshot_from_markdown(ctx, actor, slug, &markdown, operation, reason, metadata) + .await + .map(Some) +} + +pub async fn list_revisions( + ctx: &AppContext, + slug: Option<&str>, + limit: u64, +) -> Result> { + let mut query = post_revisions::Entity::find().order_by(post_revisions::Column::CreatedAt, Order::Desc); + + if let Some(slug) = slug.map(str::trim).filter(|value| !value.is_empty()) { + query = query.filter(post_revisions::Column::PostSlug.eq(slug)); + } + + query + .limit(limit) + .all(&ctx.db) + .await + .map_err(Into::into) +} + +pub async fn get_revision(ctx: &AppContext, id: i32) -> Result { + post_revisions::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound) +} + +pub async fn restore_revision( + ctx: &AppContext, + actor: Option<&AdminIdentity>, + revision_id: i32, + mode: &str, +) -> Result { + let revision = get_revision(ctx, revision_id).await?; + let slug = revision.post_slug.clone(); + let revision_markdown = revision + .markdown + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| Error::BadRequest("该版本没有可恢复的 Markdown 快照".to_string()))?; + let restore_mode = RestoreMode::parse(mode); + + let _ = capture_current_snapshot( + ctx, + actor, + &slug, + "restore_backup", + Some("恢复前自动备份"), + Some(serde_json::json!({ + "source_revision_id": revision_id, + "mode": restore_mode.as_str(), + })), + ) + .await?; + + let markdown = match restore_mode { + RestoreMode::Full => revision_markdown.clone(), + RestoreMode::Markdown | RestoreMode::Metadata => { + let (_path, current_markdown) = content::read_markdown_document(&slug).map_err(|_| { + Error::BadRequest("当前文章不存在,无法执行局部恢复,请改用完整恢复".to_string()) + })?; + let revision_post = + content::parse_markdown_source(&slug, &revision_markdown, &content::markdown_post_path(&slug).to_string_lossy())?; + let current_post = + content::parse_markdown_source(&slug, ¤t_markdown, &content::markdown_post_path(&slug).to_string_lossy())?; + let mut merged = current_post.clone(); + match restore_mode { + RestoreMode::Markdown => { + merged.content = revision_post.content; + } + RestoreMode::Metadata => { + merged.title = revision_post.title; + merged.description = revision_post.description; + merged.category = revision_post.category; + merged.tags = revision_post.tags; + merged.post_type = revision_post.post_type; + merged.image = revision_post.image; + merged.images = revision_post.images; + merged.pinned = revision_post.pinned; + merged.status = revision_post.status; + merged.visibility = revision_post.visibility; + merged.publish_at = revision_post.publish_at; + merged.unpublish_at = revision_post.unpublish_at; + merged.canonical_url = revision_post.canonical_url; + merged.noindex = revision_post.noindex; + merged.og_image = revision_post.og_image; + merged.redirect_from = revision_post.redirect_from; + merged.redirect_to = revision_post.redirect_to; + } + RestoreMode::Full => unreachable!(), + } + content::build_markdown_document(&merged) + } + }; + + fs::create_dir_all(content::MARKDOWN_POSTS_DIR).map_err(|error| Error::BadRequest(error.to_string()))?; + fs::write(content::markdown_post_path(&slug), markdown.replace("\r\n", "\n")) + .map_err(|error| Error::BadRequest(error.to_string()))?; + content::sync_markdown_posts(ctx).await?; + + let _ = capture_snapshot_from_markdown( + ctx, + actor, + &slug, + &markdown, + "restore", + Some("通过版本历史恢复"), + Some(serde_json::json!({ + "source_revision_id": revision_id, + "mode": restore_mode.as_str(), + })), + ) + .await?; + + Ok(revision) +} diff --git a/backend/src/services/storage.rs b/backend/src/services/storage.rs new file mode 100644 index 0000000..7282277 --- /dev/null +++ b/backend/src/services/storage.rs @@ -0,0 +1,513 @@ +use aws_config::BehaviorVersion; +use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client}; +use loco_rs::prelude::*; +use sea_orm::{EntityTrait, QueryOrder}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +use crate::models::_entities::site_settings; + +const ENV_MEDIA_PROVIDER: &str = "TERMI_MEDIA_PROVIDER"; +const ENV_MEDIA_ENDPOINT: &str = "TERMI_MEDIA_ENDPOINT"; +const ENV_MEDIA_BUCKET: &str = "TERMI_MEDIA_BUCKET"; +const ENV_MEDIA_PUBLIC_BASE_URL: &str = "TERMI_MEDIA_PUBLIC_BASE_URL"; +const ENV_MEDIA_ACCESS_KEY_ID: &str = "TERMI_MEDIA_ACCESS_KEY_ID"; +const ENV_MEDIA_SECRET_ACCESS_KEY: &str = "TERMI_MEDIA_SECRET_ACCESS_KEY"; + +const ENV_R2_ACCOUNT_ID: &str = "TERMI_R2_ACCOUNT_ID"; +const ENV_R2_BUCKET: &str = "TERMI_R2_BUCKET"; +const ENV_R2_PUBLIC_BASE_URL: &str = "TERMI_R2_PUBLIC_BASE_URL"; +const ENV_R2_ACCESS_KEY_ID: &str = "TERMI_R2_ACCESS_KEY_ID"; +const ENV_R2_SECRET_ACCESS_KEY: &str = "TERMI_R2_SECRET_ACCESS_KEY"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MediaStorageProvider { + R2, + Minio, +} + +#[derive(Clone, Debug)] +pub struct MediaStorageSettings { + pub provider: MediaStorageProvider, + pub provider_name: String, + pub endpoint: String, + pub bucket: String, + pub public_base_url: String, + pub access_key_id: String, + pub secret_access_key: String, + pub region: String, + pub force_path_style: bool, +} + +#[derive(Clone, Debug)] +pub struct StoredObject { + pub key: String, + pub url: String, +} + +#[derive(Clone, Debug)] +pub struct StoredObjectSummary { + pub key: String, + pub url: String, + pub size_bytes: i64, + pub last_modified: Option, +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn env_value(name: &str) -> Option { + std::env::var(name) + .ok() + .and_then(|value| trim_to_option(Some(value))) +} + +fn slugify_segment(value: &str) -> String { + let mut output = String::new(); + let mut previous_dash = false; + + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + output.push(ch.to_ascii_lowercase()); + previous_dash = false; + } else if !previous_dash { + output.push('-'); + previous_dash = true; + } + } + + output.trim_matches('-').to_string() +} + +fn normalize_public_base_url(value: String) -> String { + value.trim().trim_end_matches('/').to_string() +} + +fn normalize_provider(value: Option) -> MediaStorageProvider { + match value + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "minio" => MediaStorageProvider::Minio, + _ => MediaStorageProvider::R2, + } +} + +async fn load_settings_row(ctx: &AppContext) -> Result> { + site_settings::Entity::find() + .order_by_asc(site_settings::Column::Id) + .one(&ctx.db) + .await + .map_err(Into::into) +} + +fn build_r2_endpoint(account_id: &str) -> String { + let trimmed = account_id.trim().trim_end_matches('/'); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("https://{trimmed}.r2.cloudflarestorage.com") + } +} + +fn default_public_base_url_for_minio(endpoint: &str, bucket: &str) -> String { + format!( + "{}/{}", + endpoint.trim().trim_end_matches('/'), + bucket.trim().trim_start_matches('/') + ) +} + +pub fn generated_cover_directory() -> PathBuf { + let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let candidates = [ + current_dir + .join("frontend") + .join("public") + .join("generated-covers"), + current_dir + .join("..") + .join("frontend") + .join("public") + .join("generated-covers"), + ]; + + candidates + .into_iter() + .find(|path| path.parent().map(|parent| parent.exists()).unwrap_or(false)) + .unwrap_or_else(|| { + PathBuf::from("..") + .join("frontend") + .join("public") + .join("generated-covers") + }) +} + +pub fn file_extension_for_path(path: &Path) -> &str { + path.extension() + .and_then(|ext| ext.to_str()) + .map(str::trim) + .filter(|ext| !ext.is_empty()) + .unwrap_or("png") +} + +pub async fn optional_r2_settings(ctx: &AppContext) -> Result> { + let row = load_settings_row(ctx).await?; + + let provider_raw = row + .as_ref() + .and_then(|item| trim_to_option(item.media_storage_provider.clone())) + .or_else(|| env_value(ENV_MEDIA_PROVIDER)); + let provider = normalize_provider(provider_raw.clone()); + + let endpoint_or_account = row + .as_ref() + .and_then(|item| trim_to_option(item.media_r2_account_id.clone())) + .or_else(|| env_value(ENV_MEDIA_ENDPOINT)) + .or_else(|| env_value(ENV_R2_ACCOUNT_ID)); + let bucket = row + .as_ref() + .and_then(|item| trim_to_option(item.media_r2_bucket.clone())) + .or_else(|| env_value(ENV_MEDIA_BUCKET)) + .or_else(|| env_value(ENV_R2_BUCKET)); + let access_key_id = row + .as_ref() + .and_then(|item| trim_to_option(item.media_r2_access_key_id.clone())) + .or_else(|| env_value(ENV_MEDIA_ACCESS_KEY_ID)) + .or_else(|| env_value(ENV_R2_ACCESS_KEY_ID)); + let secret_access_key = row + .as_ref() + .and_then(|item| trim_to_option(item.media_r2_secret_access_key.clone())) + .or_else(|| env_value(ENV_MEDIA_SECRET_ACCESS_KEY)) + .or_else(|| env_value(ENV_R2_SECRET_ACCESS_KEY)); + + let public_base_url = row + .as_ref() + .and_then(|item| trim_to_option(item.media_r2_public_base_url.clone())) + .or_else(|| env_value(ENV_MEDIA_PUBLIC_BASE_URL)) + .or_else(|| env_value(ENV_R2_PUBLIC_BASE_URL)) + .or_else( + || match (&provider, endpoint_or_account.as_deref(), bucket.as_deref()) { + (MediaStorageProvider::Minio, Some(endpoint), Some(bucket)) => { + Some(default_public_base_url_for_minio(endpoint, bucket)) + } + _ => None, + }, + ); + + let has_any = endpoint_or_account.is_some() + || bucket.is_some() + || public_base_url.is_some() + || access_key_id.is_some() + || secret_access_key.is_some() + || provider_raw.is_some(); + + if !has_any { + return Ok(None); + } + + let missing = [ + ( + match provider { + MediaStorageProvider::Minio => "Endpoint", + MediaStorageProvider::R2 => "Account ID", + }, + endpoint_or_account.is_some(), + ), + ("Bucket", bucket.is_some()), + ("Public Base URL", public_base_url.is_some()), + ("Access Key ID", access_key_id.is_some()), + ("Secret Access Key", secret_access_key.is_some()), + ] + .into_iter() + .filter_map(|(label, present)| (!present).then_some(label)) + .collect::>(); + + if !missing.is_empty() { + return Err(Error::BadRequest(format!( + "对象存储配置不完整,请补齐:{}", + missing.join(" / ") + ))); + } + + let endpoint = match provider { + MediaStorageProvider::R2 => { + build_r2_endpoint(&endpoint_or_account.clone().unwrap_or_default()) + } + MediaStorageProvider::Minio => endpoint_or_account.clone().unwrap_or_default(), + }; + + Ok(Some(MediaStorageSettings { + provider: provider.clone(), + provider_name: match provider { + MediaStorageProvider::R2 => "r2".to_string(), + MediaStorageProvider::Minio => "minio".to_string(), + }, + endpoint, + bucket: bucket.unwrap_or_default(), + public_base_url: normalize_public_base_url(public_base_url.unwrap_or_default()), + access_key_id: access_key_id.unwrap_or_default(), + secret_access_key: secret_access_key.unwrap_or_default(), + region: match provider { + MediaStorageProvider::R2 => "auto".to_string(), + MediaStorageProvider::Minio => "us-east-1".to_string(), + }, + force_path_style: provider == MediaStorageProvider::Minio, + })) +} + +pub async fn require_r2_settings(ctx: &AppContext) -> Result { + optional_r2_settings(ctx) + .await? + .ok_or_else(|| Error::BadRequest("请先在后台配置媒体对象存储".to_string())) +} + +async fn r2_client(settings: &MediaStorageSettings) -> Client { + let shared_config = aws_config::defaults(BehaviorVersion::latest()) + .endpoint_url(settings.endpoint.clone()) + .credentials_provider(Credentials::new( + settings.access_key_id.clone(), + settings.secret_access_key.clone(), + None, + None, + match settings.provider { + MediaStorageProvider::R2 => "r2", + MediaStorageProvider::Minio => "minio", + }, + )) + .region(aws_sdk_s3::config::Region::new(settings.region.clone())) + .load() + .await; + + let conf = aws_sdk_s3::config::Builder::from(&shared_config) + .force_path_style(settings.force_path_style) + .build(); + + Client::from_conf(conf) +} + +fn build_public_url(settings: &MediaStorageSettings, key: &str) -> String { + format!( + "{}/{}", + settings.public_base_url, + key.trim_start_matches('/') + ) +} + +pub fn object_key_from_public_url(settings: &MediaStorageSettings, url: &str) -> Option { + let normalized_base = settings.public_base_url.trim().trim_end_matches('/'); + let normalized_url = url.trim(); + + if normalized_base.is_empty() || normalized_url.is_empty() { + return None; + } + + normalized_url + .strip_prefix(normalized_base) + .map(|suffix| suffix.trim_start_matches('/').to_string()) + .filter(|suffix| !suffix.is_empty()) +} + +pub fn build_object_key(prefix: &str, stem: &str, extension: &str) -> String { + let safe_prefix = prefix.trim_matches('/'); + let safe_stem = slugify_segment(stem); + let safe_extension = extension + .trim() + .trim_start_matches('.') + .to_ascii_lowercase(); + let object_name = format!( + "{}-{}.{}", + if safe_stem.is_empty() { + "asset".to_string() + } else { + safe_stem + }, + Uuid::new_v4().simple(), + if safe_extension.is_empty() { + "bin".to_string() + } else { + safe_extension + }, + ); + + if safe_prefix.is_empty() { + object_name + } else { + format!("{safe_prefix}/{object_name}") + } +} + +async fn ensure_bucket_exists(client: &Client, settings: &MediaStorageSettings) -> Result<()> { + if client + .head_bucket() + .bucket(&settings.bucket) + .send() + .await + .is_ok() + { + return Ok(()); + } + + if settings.provider != MediaStorageProvider::Minio { + return Err(Error::BadRequest(format!( + "对象存储 bucket 不存在或不可访问:{}", + settings.bucket + ))); + } + + client + .create_bucket() + .bucket(&settings.bucket) + .send() + .await + .map_err(|error| Error::BadRequest(format!("自动创建 MinIO bucket 失败: {error}")))?; + + Ok(()) +} + +pub async fn upload_bytes_to_r2( + ctx: &AppContext, + key: &str, + bytes: Vec, + content_type: Option<&str>, + cache_control: Option<&str>, +) -> Result { + let settings = require_r2_settings(ctx).await?; + let client = r2_client(&settings).await; + ensure_bucket_exists(&client, &settings).await?; + + let mut request = client + .put_object() + .bucket(&settings.bucket) + .key(key) + .body(ByteStream::from(bytes)); + + if let Some(content_type) = content_type + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request = request.content_type(content_type); + } + + if let Some(cache_control) = cache_control + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request = request.cache_control(cache_control); + } + + request + .send() + .await + .map_err(|error| Error::BadRequest(format!("上传文件到对象存储失败: {error}")))?; + + Ok(StoredObject { + key: key.to_string(), + url: build_public_url(&settings, key), + }) +} + +pub async fn delete_object(ctx: &AppContext, key: &str) -> Result<()> { + let settings = require_r2_settings(ctx).await?; + let client = r2_client(&settings).await; + client + .delete_object() + .bucket(&settings.bucket) + .key(key) + .send() + .await + .map_err(|error| Error::BadRequest(format!("删除对象存储文件失败: {error}")))?; + Ok(()) +} + +pub async fn delete_managed_url(ctx: &AppContext, url: &str) -> Result { + let Some(settings) = optional_r2_settings(ctx).await? else { + return Ok(false); + }; + let Some(key) = object_key_from_public_url(&settings, url) else { + return Ok(false); + }; + let client = r2_client(&settings).await; + client + .delete_object() + .bucket(&settings.bucket) + .key(&key) + .send() + .await + .map_err(|error| Error::BadRequest(format!("删除对象存储文件失败: {error}")))?; + Ok(true) +} + +pub async fn list_objects( + ctx: &AppContext, + prefix: Option<&str>, + limit: i32, +) -> Result> { + let settings = require_r2_settings(ctx).await?; + let client = r2_client(&settings).await; + ensure_bucket_exists(&client, &settings).await?; + + let mut request = client + .list_objects_v2() + .bucket(&settings.bucket) + .max_keys(limit.clamp(1, 1000)); + + if let Some(prefix) = prefix.map(str::trim).filter(|value| !value.is_empty()) { + request = request.prefix(prefix); + } + + let result = request + .send() + .await + .map_err(|error| Error::BadRequest(format!("读取对象存储列表失败: {error}")))?; + + Ok(result + .contents() + .iter() + .filter_map(|item| { + let key = item.key()?.to_string(); + Some(StoredObjectSummary { + url: build_public_url(&settings, &key), + key, + size_bytes: item.size().unwrap_or_default(), + last_modified: item.last_modified().map(|ts| format!("{ts:?}")), + }) + }) + .collect()) +} + +pub async fn test_r2_connectivity(ctx: &AppContext) -> Result { + let settings = require_r2_settings(ctx).await?; + let client = r2_client(&settings).await; + ensure_bucket_exists(&client, &settings).await?; + let healthcheck_key = format!(".healthchecks/{}.txt", Uuid::new_v4().simple()); + client + .put_object() + .bucket(&settings.bucket) + .key(&healthcheck_key) + .body(ByteStream::from_static(b"termi-storage-ok")) + .content_type("text/plain") + .send() + .await + .map_err(|error| Error::BadRequest(format!("对象存储连接测试失败: {error}")))?; + client + .delete_object() + .bucket(&settings.bucket) + .key(&healthcheck_key) + .send() + .await + .map_err(|error| Error::BadRequest(format!("对象存储清理测试文件失败: {error}")))?; + + Ok(settings.bucket) +} diff --git a/backend/src/services/subscriptions.rs b/backend/src/services/subscriptions.rs new file mode 100644 index 0000000..001a079 --- /dev/null +++ b/backend/src/services/subscriptions.rs @@ -0,0 +1,1216 @@ +use chrono::{Duration, Utc}; +use loco_rs::{ + bgworker::BackgroundWorker, + prelude::*, +}; +use reqwest::Client; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, Order, QueryFilter, QueryOrder, + QuerySelect, Set, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::{ + mailers::subscription::SubscriptionMailer, + models::_entities::{notification_deliveries, posts, subscriptions}, + services::content, + workers::notification_delivery::{ + NotificationDeliveryWorker, NotificationDeliveryWorkerArgs, + }, +}; + +pub const CHANNEL_EMAIL: &str = "email"; +pub const CHANNEL_WEBHOOK: &str = "webhook"; +pub const CHANNEL_DISCORD: &str = "discord"; +pub const CHANNEL_TELEGRAM: &str = "telegram"; +pub const CHANNEL_NTFY: &str = "ntfy"; + +pub const STATUS_PENDING: &str = "pending"; +pub const STATUS_ACTIVE: &str = "active"; +pub const STATUS_PAUSED: &str = "paused"; +pub const STATUS_UNSUBSCRIBED: &str = "unsubscribed"; + +pub const EVENT_POST_PUBLISHED: &str = "post.published"; +pub const EVENT_DIGEST_WEEKLY: &str = "digest.weekly"; +pub const EVENT_DIGEST_MONTHLY: &str = "digest.monthly"; +pub const EVENT_SUBSCRIPTION_TEST: &str = "subscription.test"; +pub const EVENT_COMMENT_CREATED: &str = "comment.created"; +pub const EVENT_FRIEND_LINK_CREATED: &str = "friend_link.created"; + +pub const DELIVERY_STATUS_QUEUED: &str = "queued"; +pub const DELIVERY_STATUS_SENT: &str = "sent"; +pub const DELIVERY_STATUS_RETRY_PENDING: &str = "retry_pending"; +pub const DELIVERY_STATUS_EXHAUSTED: &str = "exhausted"; +pub const DELIVERY_STATUS_SKIPPED: &str = "skipped"; + +const MAX_DELIVERY_ATTEMPTS: i32 = 5; + +#[derive(Clone, Debug, Serialize)] +pub struct DigestDispatchSummary { + pub period: String, + pub post_count: usize, + pub queued: usize, + pub skipped: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct QueueDispatchSummary { + pub queued: usize, + pub skipped: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicSubscriptionView { + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub id: i32, + pub channel_type: String, + pub target: String, + pub display_name: Option, + pub status: String, + pub filters: Option, + pub metadata: Option, + pub verified_at: Option, + pub last_notified_at: Option, + pub last_delivery_status: Option, + pub manage_token: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublicSubscriptionResult { + pub subscription: PublicSubscriptionView, + pub requires_confirmation: bool, + pub message: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct DeliveryEnvelope { + event: String, + site_name: Option, + site_url: Option, + timestamp: String, + subject: String, + text: String, + data: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct QueuedDeliveryPayload { + subject: String, + headline: String, + text: String, + site_name: Option, + site_url: Option, + payload: Value, + manage_url: Option, + unsubscribe_url: Option, +} + +#[derive(Clone, Debug)] +struct SiteContext { + site_name: Option, + site_url: Option, +} + +fn trim_to_option(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalize_string(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +pub fn generate_subscription_token() -> String { + Uuid::new_v4().simple().to_string() +} + +pub fn normalize_channel_type(value: &str) -> String { + match value.trim().to_ascii_lowercase().as_str() { + CHANNEL_WEBHOOK => CHANNEL_WEBHOOK.to_string(), + CHANNEL_DISCORD => CHANNEL_DISCORD.to_string(), + CHANNEL_TELEGRAM => CHANNEL_TELEGRAM.to_string(), + CHANNEL_NTFY => CHANNEL_NTFY.to_string(), + _ => CHANNEL_EMAIL.to_string(), + } +} + +pub fn normalize_status(value: &str) -> String { + match value.trim().to_ascii_lowercase().as_str() { + STATUS_PENDING => STATUS_PENDING.to_string(), + STATUS_PAUSED => STATUS_PAUSED.to_string(), + STATUS_UNSUBSCRIBED => STATUS_UNSUBSCRIBED.to_string(), + _ => STATUS_ACTIVE.to_string(), + } +} + +fn default_public_filters() -> Value { + serde_json::json!({ + "event_types": [ + EVENT_POST_PUBLISHED, + EVENT_DIGEST_WEEKLY, + EVENT_DIGEST_MONTHLY, + ] + }) +} + +fn normalize_string_list(items: &[Value]) -> Vec { + let mut seen = std::collections::HashSet::new(); + items + .iter() + .filter_map(Value::as_str) + .map(normalize_string) + .filter(|item| !item.is_empty()) + .filter(|item| seen.insert(item.clone())) + .map(Value::String) + .collect() +} + +pub fn normalize_filters(value: Option) -> Option { + let normalized = match value { + None | Some(Value::Null) => None, + Some(Value::Array(items)) => Some(serde_json::json!({ + "event_types": normalize_string_list(&items) + })), + Some(Value::Object(mut object)) => { + if let Some(items) = object.get("event_types").and_then(Value::as_array) { + object.insert( + "event_types".to_string(), + Value::Array(normalize_string_list(items)), + ); + } + if let Some(items) = object.get("categories").and_then(Value::as_array) { + object.insert( + "categories".to_string(), + Value::Array(normalize_string_list(items)), + ); + } + if let Some(items) = object.get("tags").and_then(Value::as_array) { + object.insert( + "tags".to_string(), + Value::Array(normalize_string_list(items)), + ); + } + Some(Value::Object(object)) + } + Some(other) => Some(other), + }; + + normalized.and_then(|value| match value { + Value::Object(object) if object.is_empty() => None, + other => Some(other), + }) +} + +fn merge_metadata(existing: Option<&Value>, incoming: Option) -> Option { + match (existing, incoming) { + (Some(Value::Object(left)), Some(Value::Object(right))) => { + let mut merged = left.clone(); + for (key, value) in right { + merged.insert(key, value); + } + Some(Value::Object(merged)) + } + (_, Some(value)) => Some(value), + (Some(value), None) => Some(value.clone()), + (None, None) => None, + } +} + +fn json_string_list(value: Option<&Value>, key: &str) -> Vec { + value + .and_then(Value::as_object) + .and_then(|object| object.get(key)) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(normalize_string) + .filter(|item| !item.is_empty()) + .collect::>() + }) + .unwrap_or_default() +} + +fn payload_match_strings(payload: &Value, key: &str) -> Vec { + let mut values = Vec::new(); + + if let Some(item) = payload.get(key).and_then(Value::as_str) { + let normalized = normalize_string(item); + if !normalized.is_empty() { + values.push(normalized); + } + } + + if let Some(items) = payload.get(key).and_then(Value::as_array) { + values.extend( + items.iter() + .filter_map(Value::as_str) + .map(normalize_string) + .filter(|item| !item.is_empty()), + ); + } + + if let Some(posts) = payload.get("posts").and_then(Value::as_array) { + for post in posts { + if let Some(item) = post.get(key).and_then(Value::as_str) { + let normalized = normalize_string(item); + if !normalized.is_empty() { + values.push(normalized); + } + } + + if let Some(items) = post.get(key).and_then(Value::as_array) { + values.extend( + items.iter() + .filter_map(Value::as_str) + .map(normalize_string) + .filter(|item| !item.is_empty()), + ); + } + } + } + + values.sort(); + values.dedup(); + values +} + +fn delivery_retry_delay(attempts: i32) -> Duration { + match attempts { + 0 | 1 => Duration::minutes(1), + 2 => Duration::minutes(5), + 3 => Duration::minutes(15), + 4 => Duration::minutes(60), + _ => Duration::hours(6), + } +} + +fn effective_period(period: &str) -> (&'static str, i64, &'static str) { + match period.trim().to_ascii_lowercase().as_str() { + "monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY), + _ => ("weekly", 7, EVENT_DIGEST_WEEKLY), + } +} + +fn trim_site_url(value: Option) -> Option { + trim_to_option(value).map(|item| item.trim_end_matches('/').to_string()) +} + +fn build_token_link(site_url: Option<&str>, path: &str, token: &str) -> Option { + let base = site_url?.trim().trim_end_matches('/'); + if base.is_empty() { + return None; + } + + Some(format!("{base}{path}?token={token}")) +} + +fn post_public_url(site_url: Option<&str>, slug: &str) -> Option { + let site_url = site_url?.trim().trim_end_matches('/'); + if site_url.is_empty() { + return None; + } + + Some(format!("{site_url}/articles/{slug}")) +} + +async fn load_site_context(ctx: &AppContext) -> SiteContext { + match crate::controllers::site_settings::load_current(ctx).await { + Ok(settings) => SiteContext { + site_name: trim_to_option(settings.site_name), + site_url: trim_site_url(settings.site_url), + }, + Err(error) => { + tracing::warn!("failed to load site settings for subscriptions: {error}"); + SiteContext { + site_name: Some("Termi".to_string()), + site_url: Some(ctx.config.server.full_url()), + } + } + } +} + +pub async fn ensure_subscription_tokens( + ctx: &AppContext, + item: &subscriptions::Model, +) -> Result { + let mut active = item.clone().into_active_model(); + let mut changed = false; + + if item + .manage_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + active.manage_token = Set(Some(generate_subscription_token())); + changed = true; + } + + if changed { + active.update(&ctx.db).await.map_err(Into::into) + } else { + Ok(item.clone()) + } +} + +pub fn to_public_subscription_view(item: &subscriptions::Model) -> PublicSubscriptionView { + PublicSubscriptionView { + created_at: item.created_at.clone(), + updated_at: item.updated_at.clone(), + id: item.id, + channel_type: item.channel_type.clone(), + target: item.target.clone(), + display_name: item.display_name.clone(), + status: item.status.clone(), + filters: item.filters.clone(), + metadata: item.metadata.clone(), + verified_at: item.verified_at.clone(), + last_notified_at: item.last_notified_at.clone(), + last_delivery_status: item.last_delivery_status.clone(), + manage_token: item.manage_token.clone(), + } +} + +fn subscription_links(item: &subscriptions::Model, site_context: &SiteContext) -> (Option, Option, Option) { + let manage_url = item + .manage_token + .as_deref() + .and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/manage", token)); + let unsubscribe_url = item + .manage_token + .as_deref() + .and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/unsubscribe", token)); + let confirm_url = item + .confirm_token + .as_deref() + .and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/confirm", token)); + + (manage_url, unsubscribe_url, confirm_url) +} + +async fn send_confirmation_email(ctx: &AppContext, item: &subscriptions::Model) -> Result<()> { + if item.channel_type != CHANNEL_EMAIL { + return Ok(()); + } + + let site_context = load_site_context(ctx).await; + let (manage_url, _, confirm_url) = subscription_links(item, &site_context); + let confirm_url = confirm_url.ok_or_else(|| { + Error::BadRequest("站点未配置 site_url,暂时无法生成订阅确认链接".to_string()) + })?; + + SubscriptionMailer::send_confirmation( + ctx, + &item.target, + site_context.site_name.as_deref(), + site_context.site_url.as_deref(), + &confirm_url, + manage_url.as_deref(), + ) + .await +} + +fn subscription_allows_event(item: &subscriptions::Model, event_type: &str, payload: &Value) -> bool { + if normalize_status(&item.status) != STATUS_ACTIVE { + return false; + } + + if item.channel_type == CHANNEL_EMAIL + && item + .verified_at + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + return false; + } + + let normalized_event = normalize_string(event_type); + let event_types = json_string_list(item.filters.as_ref(), "event_types"); + if !event_types.is_empty() && !event_types.iter().any(|item| item == &normalized_event) { + return false; + } + + let categories = json_string_list(item.filters.as_ref(), "categories"); + if !categories.is_empty() { + let payload_categories = payload_match_strings(payload, "category"); + if payload_categories.is_empty() + || !categories + .iter() + .any(|category| payload_categories.iter().any(|item| item == category)) + { + return false; + } + } + + let tags = json_string_list(item.filters.as_ref(), "tags"); + if !tags.is_empty() { + let payload_tags = payload_match_strings(payload, "tags"); + if payload_tags.is_empty() + || !tags.iter().any(|tag| payload_tags.iter().any(|item| item == tag)) + { + return false; + } + } + + true +} + +pub async fn list_subscriptions( + ctx: &AppContext, + channel_type: Option<&str>, + status: Option<&str>, +) -> Result> { + let mut query = subscriptions::Entity::find().order_by(subscriptions::Column::CreatedAt, Order::Desc); + + if let Some(channel_type) = channel_type.map(str::trim).filter(|value| !value.is_empty()) { + query = query.filter(subscriptions::Column::ChannelType.eq(normalize_channel_type(channel_type))); + } + + if let Some(status) = status.map(str::trim).filter(|value| !value.is_empty()) { + query = query.filter(subscriptions::Column::Status.eq(normalize_status(status))); + } + + query.all(&ctx.db).await.map_err(Into::into) +} + +pub async fn list_recent_deliveries( + ctx: &AppContext, + limit: u64, +) -> Result> { + notification_deliveries::Entity::find() + .order_by(notification_deliveries::Column::CreatedAt, Order::Desc) + .limit(limit) + .all(&ctx.db) + .await + .map_err(Into::into) +} + +pub async fn create_public_email_subscription( + ctx: &AppContext, + email: &str, + display_name: Option, + metadata: Option, +) -> Result { + let normalized_email = normalize_string(email); + if normalized_email.is_empty() || !normalized_email.contains('@') { + return Err(Error::BadRequest("请输入有效邮箱地址".to_string())); + } + + let existing = subscriptions::Entity::find() + .filter(subscriptions::Column::ChannelType.eq(CHANNEL_EMAIL)) + .filter(subscriptions::Column::Target.eq(&normalized_email)) + .one(&ctx.db) + .await?; + + let display_name = trim_to_option(display_name); + + if let Some(existing) = existing { + let mut active = existing.clone().into_active_model(); + let manage_token = existing + .manage_token + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(generate_subscription_token); + active.manage_token = Set(Some(manage_token)); + + if display_name.is_some() { + active.display_name = Set(display_name); + } + active.metadata = Set(merge_metadata(existing.metadata.as_ref(), metadata)); + + let normalized_status = normalize_status(&existing.status); + if existing.verified_at.is_some() && normalized_status != STATUS_UNSUBSCRIBED { + if normalized_status != STATUS_ACTIVE { + active.status = Set(STATUS_ACTIVE.to_string()); + } + active.confirm_token = Set(None); + let updated = active.update(&ctx.db).await?; + let message = if normalized_status == STATUS_PAUSED { + "该邮箱已重新启用订阅。".to_string() + } else { + "该邮箱已经订阅,可直接使用邮件中的管理链接调整偏好。".to_string() + }; + return Ok(PublicSubscriptionResult { + subscription: to_public_subscription_view(&updated), + requires_confirmation: false, + message, + }); + } + + active.status = Set(STATUS_PENDING.to_string()); + active.confirm_token = Set(Some(generate_subscription_token())); + active.verified_at = Set(None); + let updated = active.update(&ctx.db).await?; + send_confirmation_email(ctx, &updated).await?; + return Ok(PublicSubscriptionResult { + subscription: to_public_subscription_view(&updated), + requires_confirmation: true, + message: "订阅申请已收到,请前往邮箱点击确认链接后生效。".to_string(), + }); + } + + let created = subscriptions::ActiveModel { + channel_type: Set(CHANNEL_EMAIL.to_string()), + target: Set(normalized_email), + display_name: Set(display_name), + status: Set(STATUS_PENDING.to_string()), + filters: Set(Some(default_public_filters())), + secret: Set(None), + notes: Set(None), + confirm_token: Set(Some(generate_subscription_token())), + manage_token: Set(Some(generate_subscription_token())), + metadata: Set(metadata), + verified_at: Set(None), + last_notified_at: Set(None), + failure_count: Set(Some(0)), + last_delivery_status: Set(None), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + send_confirmation_email(ctx, &created).await?; + + Ok(PublicSubscriptionResult { + subscription: to_public_subscription_view(&created), + requires_confirmation: true, + message: "订阅申请已收到,请前往邮箱点击确认链接后生效。".to_string(), + }) +} + +pub async fn confirm_subscription(ctx: &AppContext, token: &str) -> Result { + let token = token.trim(); + if token.is_empty() { + return Err(Error::BadRequest("确认令牌不能为空".to_string())); + } + + let item = subscriptions::Entity::find() + .filter(subscriptions::Column::ConfirmToken.eq(token)) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::BadRequest("确认链接无效或已过期".to_string()))?; + + if normalize_status(&item.status) == STATUS_ACTIVE + && item + .verified_at + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + return Ok(item); + } + + let mut active = item.clone().into_active_model(); + active.status = Set(STATUS_ACTIVE.to_string()); + if item + .verified_at + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + { + active.verified_at = Set(Some(Utc::now().to_rfc3339())); + } + active.update(&ctx.db).await.map_err(Into::into) +} + +pub async fn get_subscription_by_manage_token( + ctx: &AppContext, + token: &str, +) -> Result { + let token = token.trim(); + if token.is_empty() { + return Err(Error::BadRequest("管理令牌不能为空".to_string())); + } + + subscriptions::Entity::find() + .filter(subscriptions::Column::ManageToken.eq(token)) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::BadRequest("管理链接无效或已过期".to_string())) +} + +pub async fn update_subscription_preferences( + ctx: &AppContext, + token: &str, + display_name: Option, + status: Option, + filters: Option, +) -> Result { + let item = get_subscription_by_manage_token(ctx, token).await?; + let mut active = item.into_active_model(); + + if display_name.is_some() { + active.display_name = Set(trim_to_option(display_name)); + } + + if let Some(status) = status { + let normalized = normalize_status(&status); + if normalized == STATUS_PENDING { + return Err(Error::BadRequest("偏好页不支持将状态改回 pending".to_string())); + } + active.status = Set(normalized); + } + + if filters.is_some() { + active.filters = Set(normalize_filters(filters)); + } + + active.update(&ctx.db).await.map_err(Into::into) +} + +pub async fn unsubscribe_subscription(ctx: &AppContext, token: &str) -> Result { + let item = get_subscription_by_manage_token(ctx, token).await?; + let mut active = item.into_active_model(); + active.status = Set(STATUS_UNSUBSCRIBED.to_string()); + active.confirm_token = Set(None); + active.update(&ctx.db).await.map_err(Into::into) +} + +async fn active_subscriptions(ctx: &AppContext) -> Result> { + subscriptions::Entity::find() + .filter(subscriptions::Column::Status.eq(STATUS_ACTIVE)) + .order_by(subscriptions::Column::CreatedAt, Order::Asc) + .all(&ctx.db) + .await + .map_err(Into::into) +} + +async fn update_subscription_delivery_state( + ctx: &AppContext, + subscription_id: Option, + status: &str, + success: bool, +) -> Result<()> { + let Some(subscription_id) = subscription_id else { + return Ok(()); + }; + + let Some(subscription) = subscriptions::Entity::find_by_id(subscription_id) + .one(&ctx.db) + .await? + else { + return Ok(()); + }; + + let current_failures = subscription.failure_count.unwrap_or(0); + let mut active = subscription.into_active_model(); + active.last_notified_at = Set(Some(Utc::now().to_rfc3339())); + active.last_delivery_status = Set(Some(status.to_string())); + active.failure_count = Set(Some(if success { + 0 + } else { + current_failures + 1 + })); + let _ = active.update(&ctx.db).await?; + Ok(()) +} + +async fn enqueue_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> { + match NotificationDeliveryWorker::perform_later(ctx, NotificationDeliveryWorkerArgs { delivery_id }).await { + Ok(_) => Ok(()), + Err(Error::QueueProviderMissing) => process_delivery(ctx, delivery_id).await, + Err(error) => { + tracing::warn!("failed to enqueue delivery #{delivery_id}, falling back to sync processing: {error}"); + process_delivery(ctx, delivery_id).await + } + } +} + +pub async fn queue_direct_notification( + ctx: &AppContext, + channel_type: &str, + target: &str, + event_type: &str, + subject: &str, + text: &str, + payload: Value, + site_name: Option, + site_url: Option, +) -> Result { + let channel_type = normalize_channel_type(channel_type); + let target = target.trim().to_string(); + if target.is_empty() { + return Err(Error::BadRequest("target 不能为空".to_string())); + } + + let delivery = notification_deliveries::ActiveModel { + subscription_id: Set(None), + channel_type: Set(channel_type), + target: Set(target), + event_type: Set(event_type.to_string()), + status: Set(DELIVERY_STATUS_QUEUED.to_string()), + provider: Set(None), + response_text: Set(None), + payload: Set(Some(serde_json::to_value(QueuedDeliveryPayload { + subject: subject.to_string(), + headline: subject.to_string(), + text: text.to_string(), + site_name, + site_url, + payload, + manage_url: None, + unsubscribe_url: None, + })?)), + attempts_count: Set(0), + next_retry_at: Set(None), + last_attempt_at: Set(None), + delivered_at: Set(None), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + enqueue_delivery(ctx, delivery.id).await?; + Ok(delivery) +} + +async fn queue_notification_for_subscription( + ctx: &AppContext, + item: &subscriptions::Model, + event_type: &str, + subject: &str, + text: &str, + payload: Value, + site_context: &SiteContext, +) -> Result { + let item = ensure_subscription_tokens(ctx, item).await?; + let (manage_url, unsubscribe_url, _) = subscription_links(&item, site_context); + + let delivery = notification_deliveries::ActiveModel { + subscription_id: Set(Some(item.id)), + channel_type: Set(item.channel_type.clone()), + target: Set(item.target.clone()), + event_type: Set(event_type.to_string()), + status: Set(DELIVERY_STATUS_QUEUED.to_string()), + provider: Set(None), + response_text: Set(None), + payload: Set(Some(serde_json::to_value(QueuedDeliveryPayload { + subject: subject.to_string(), + headline: subject.to_string(), + text: text.to_string(), + site_name: site_context.site_name.clone(), + site_url: site_context.site_url.clone(), + payload, + manage_url, + unsubscribe_url, + })?)), + attempts_count: Set(0), + next_retry_at: Set(None), + last_attempt_at: Set(None), + delivered_at: Set(None), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + enqueue_delivery(ctx, delivery.id).await?; + Ok(delivery) +} + +pub async fn queue_event_for_active_subscriptions( + ctx: &AppContext, + event_type: &str, + subject: &str, + text: &str, + payload: Value, + site_name: Option, + site_url: Option, +) -> Result { + let subscriptions = active_subscriptions(ctx).await?; + if subscriptions.is_empty() { + return Ok(QueueDispatchSummary { queued: 0, skipped: 0 }); + } + + let site_context = SiteContext { site_name, site_url }; + let mut queued = 0usize; + let mut skipped = 0usize; + + for item in subscriptions { + if !subscription_allows_event(&item, event_type, &payload) { + skipped += 1; + continue; + } + + queue_notification_for_subscription( + ctx, + &item, + event_type, + subject, + text, + payload.clone(), + &site_context, + ) + .await?; + queued += 1; + } + + Ok(QueueDispatchSummary { queued, skipped }) +} + +fn provider_name(channel_type: &str) -> &'static str { + match channel_type { + CHANNEL_EMAIL => "smtp", + CHANNEL_DISCORD => "discord-webhook", + CHANNEL_TELEGRAM => "telegram-bot-api", + CHANNEL_NTFY => "ntfy", + _ => "webhook", + } +} + +fn resolve_ntfy_target(target: &str) -> String { + let trimmed = target.trim(); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("https://ntfy.sh/{trimmed}") + } +} + +async fn deliver_via_channel( + channel_type: &str, + target: &str, + message: &QueuedDeliveryPayload, +) -> Result> { + match channel_type { + CHANNEL_EMAIL => Err(Error::BadRequest( + "email channel must be delivered via subscription context".to_string(), + )), + CHANNEL_DISCORD => { + Client::new() + .post(target) + .json(&serde_json::json!({ "content": message.text })) + .send() + .await + .and_then(|response| response.error_for_status()) + .map(|_| None) + .map_err(|error| Error::BadRequest(error.to_string())) + } + CHANNEL_TELEGRAM => { + Client::new() + .post(target) + .json(&serde_json::json!({ "text": message.text })) + .send() + .await + .and_then(|response| response.error_for_status()) + .map(|_| None) + .map_err(|error| Error::BadRequest(error.to_string())) + } + CHANNEL_NTFY => { + Client::new() + .post(resolve_ntfy_target(target)) + .header("Title", &message.subject) + .header("Content-Type", "text/plain; charset=utf-8") + .body(message.text.clone()) + .send() + .await + .and_then(|response| response.error_for_status()) + .map(|_| None) + .map_err(|error| Error::BadRequest(error.to_string())) + } + _ => { + let envelope = DeliveryEnvelope { + event: message + .payload + .get("event_type") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_default(), + site_name: message.site_name.clone(), + site_url: message.site_url.clone(), + timestamp: Utc::now().to_rfc3339(), + subject: message.subject.clone(), + text: message.text.clone(), + data: message.payload.clone(), + }; + + Client::new() + .post(target) + .json(&envelope) + .send() + .await + .and_then(|response| response.error_for_status()) + .map(|_| None) + .map_err(|error| Error::BadRequest(error.to_string())) + } + } +} + +pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> { + let Some(delivery) = notification_deliveries::Entity::find_by_id(delivery_id) + .one(&ctx.db) + .await? + else { + return Ok(()); + }; + + if matches!(delivery.status.as_str(), DELIVERY_STATUS_SENT | DELIVERY_STATUS_SKIPPED | DELIVERY_STATUS_EXHAUSTED) { + return Ok(()); + } + + let message = delivery + .payload + .clone() + .ok_or_else(|| Error::BadRequest("delivery payload 为空".to_string())) + .and_then(|value| serde_json::from_value::(value).map_err(Into::into))?; + + let attempts = delivery.attempts_count + 1; + let now = Utc::now().to_rfc3339(); + + let subscription = match delivery.subscription_id { + Some(subscription_id) => subscriptions::Entity::find_by_id(subscription_id) + .one(&ctx.db) + .await?, + None => None, + }; + + if let Some(subscription) = subscription.as_ref() { + if normalize_status(&subscription.status) != STATUS_ACTIVE { + let mut active = delivery.into_active_model(); + active.status = Set(DELIVERY_STATUS_SKIPPED.to_string()); + active.response_text = Set(Some("subscription is not active".to_string())); + active.attempts_count = Set(attempts); + active.last_attempt_at = Set(Some(now)); + active.next_retry_at = Set(None); + active.delivered_at = Set(Some(Utc::now().to_rfc3339())); + let _ = active.update(&ctx.db).await?; + update_subscription_delivery_state(ctx, Some(subscription.id), DELIVERY_STATUS_SKIPPED, false).await?; + return Ok(()); + } + } + + let send_result = if let Some(subscription) = subscription.as_ref() { + if subscription.channel_type == CHANNEL_EMAIL { + SubscriptionMailer::send_notification( + ctx, + &subscription.target, + &message.subject, + &message.headline, + &message.text, + message.site_name.as_deref(), + message.site_url.as_deref(), + message.manage_url.as_deref(), + message.unsubscribe_url.as_deref(), + ) + .await + .map(|_| None) + } else { + deliver_via_channel(&subscription.channel_type, &subscription.target, &message).await + } + } else { + deliver_via_channel(&delivery.channel_type, &delivery.target, &message).await + }; + let subscription_id = delivery.subscription_id; + let delivery_channel_type = delivery.channel_type.clone(); + + match send_result { + Ok(response_text) => { + let mut active = delivery.into_active_model(); + active.status = Set(DELIVERY_STATUS_SENT.to_string()); + active.provider = Set(Some(provider_name(&delivery_channel_type).to_string())); + active.response_text = Set(response_text); + active.attempts_count = Set(attempts); + active.last_attempt_at = Set(Some(Utc::now().to_rfc3339())); + active.next_retry_at = Set(None); + active.delivered_at = Set(Some(Utc::now().to_rfc3339())); + let _ = active.update(&ctx.db).await?; + update_subscription_delivery_state(ctx, subscription_id, DELIVERY_STATUS_SENT, true).await?; + } + Err(error) => { + let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS) + .then(|| (Utc::now() + delivery_retry_delay(attempts)).to_rfc3339()); + let status = if next_retry_at.is_some() { + DELIVERY_STATUS_RETRY_PENDING + } else { + DELIVERY_STATUS_EXHAUSTED + }; + + let mut active = delivery.into_active_model(); + active.status = Set(status.to_string()); + active.provider = Set(Some(provider_name(&delivery_channel_type).to_string())); + active.response_text = Set(Some(error.to_string())); + active.attempts_count = Set(attempts); + active.last_attempt_at = Set(Some(Utc::now().to_rfc3339())); + active.next_retry_at = Set(next_retry_at); + active.delivered_at = Set(Some(Utc::now().to_rfc3339())); + let _ = active.update(&ctx.db).await?; + update_subscription_delivery_state(ctx, subscription_id, status, false).await?; + Err(error)?; + } + } + + Ok(()) +} + +pub async fn retry_due_deliveries(ctx: &AppContext, limit: u64) -> Result { + let now = Utc::now().to_rfc3339(); + let items = notification_deliveries::Entity::find() + .filter(notification_deliveries::Column::Status.eq(DELIVERY_STATUS_RETRY_PENDING)) + .filter(notification_deliveries::Column::NextRetryAt.lte(now)) + .order_by(notification_deliveries::Column::CreatedAt, Order::Asc) + .limit(limit) + .all(&ctx.db) + .await?; + + let mut queued = 0usize; + for item in items { + enqueue_delivery(ctx, item.id).await?; + queued += 1; + } + + Ok(queued) +} + +pub async fn send_test_notification( + ctx: &AppContext, + item: &subscriptions::Model, +) -> Result { + let site_context = load_site_context(ctx).await; + let text = format!( + "这是一条来自 {} 的测试通知。\n\n频道:{}\n目标:{}\n时间:{}", + site_context + .site_name + .clone() + .unwrap_or_else(|| "Termi".to_string()), + item.channel_type, + item.target, + Utc::now().format("%Y-%m-%d %H:%M:%S UTC") + ); + + queue_notification_for_subscription( + ctx, + item, + EVENT_SUBSCRIPTION_TEST, + "Termi 订阅测试通知", + &text, + serde_json::json!({ + "event_type": EVENT_SUBSCRIPTION_TEST, + "kind": "test", + "channel": item.channel_type, + "target": item.target, + }), + &site_context, + ) + .await +} + +pub async fn notify_post_published(ctx: &AppContext, post: &content::MarkdownPost) -> Result { + let site_context = load_site_context(ctx).await; + let public_url = post_public_url(site_context.site_url.as_deref(), &post.slug); + let subject = format!("新文章发布:{}", post.title); + let payload = serde_json::json!({ + "event_type": EVENT_POST_PUBLISHED, + "slug": post.slug, + "title": post.title, + "description": post.description, + "category": post.category, + "tags": post.tags, + "url": public_url, + "published_at": Utc::now().to_rfc3339(), + }); + let text = format!( + "《{}》已发布。\n\n分类:{}\n标签:{}\n链接:{}\n\n{}", + post.title, + post.category.clone().unwrap_or_else(|| "未分类".to_string()), + if post.tags.is_empty() { + "无".to_string() + } else { + post.tags.join(", ") + }, + public_url.clone().unwrap_or_else(|| format!("/articles/{}", post.slug)), + post.description.clone().unwrap_or_default(), + ); + + queue_event_for_active_subscriptions( + ctx, + EVENT_POST_PUBLISHED, + &subject, + &text, + payload, + site_context.site_name, + site_context.site_url, + ) + .await +} + +pub async fn send_digest(ctx: &AppContext, period: &str) -> Result { + let (normalized_period, days, event_type) = effective_period(period); + let since = Utc::now() - Duration::days(days); + let posts = posts::Entity::find() + .order_by(posts::Column::CreatedAt, Order::Desc) + .all(&ctx.db) + .await? + .into_iter() + .filter(|post| { + post.created_at.with_timezone(&Utc) >= since + && content::is_post_listed_publicly(post, Utc::now().fixed_offset()) + }) + .collect::>(); + + let site_context = load_site_context(ctx).await; + let lines = if posts.is_empty() { + vec![format!("最近 {} 天还没有新的公开文章。", days)] + } else { + posts.iter() + .map(|post| { + let url = post_public_url(site_context.site_url.as_deref(), &post.slug) + .unwrap_or_else(|| format!("/articles/{}", post.slug)); + format!( + "- {}\n {}\n {}", + post.title.clone().unwrap_or_else(|| post.slug.clone()), + url, + post.description.clone().unwrap_or_default() + ) + }) + .collect::>() + }; + + let subject = format!("{} 内容摘要", if normalized_period == "monthly" { "月报" } else { "周报" }); + let body = format!("统计周期:最近 {} 天\n\n{}", days, lines.join("\n\n")); + let payload = serde_json::json!({ + "event_type": event_type, + "period": normalized_period, + "days": days, + "post_count": posts.len(), + "posts": posts.iter().map(|post| serde_json::json!({ + "slug": post.slug, + "title": post.title, + "description": post.description, + "category": post.category, + "tags": post.tags, + })).collect::>(), + }); + + let summary = queue_event_for_active_subscriptions( + ctx, + event_type, + &subject, + &body, + payload, + site_context.site_name, + site_context.site_url, + ) + .await?; + + Ok(DigestDispatchSummary { + period: normalized_period.to_string(), + post_count: posts.len(), + queued: summary.queued, + skipped: summary.skipped, + }) +} diff --git a/backend/src/tasks/mod.rs b/backend/src/tasks/mod.rs index 8b13789..4941742 100644 --- a/backend/src/tasks/mod.rs +++ b/backend/src/tasks/mod.rs @@ -1 +1,3 @@ - +pub mod retry_deliveries; +pub mod send_monthly_digest; +pub mod send_weekly_digest; diff --git a/backend/src/tasks/retry_deliveries.rs b/backend/src/tasks/retry_deliveries.rs new file mode 100644 index 0000000..ece2529 --- /dev/null +++ b/backend/src/tasks/retry_deliveries.rs @@ -0,0 +1,26 @@ +use loco_rs::prelude::*; + +use crate::services::subscriptions; + +pub struct RetryDeliveries; + +#[async_trait] +impl Task for RetryDeliveries { + fn task(&self) -> TaskInfo { + TaskInfo { + name: "retry_deliveries".to_string(), + detail: "enqueue due notification deliveries for retry".to_string(), + } + } + + async fn run(&self, app_context: &AppContext, vars: &task::Vars) -> Result<()> { + let limit = vars + .cli + .get("limit") + .and_then(|value| value.parse::().ok()) + .unwrap_or(200); + let queued = subscriptions::retry_due_deliveries(app_context, limit).await?; + tracing::info!("retry_deliveries queued {queued} jobs"); + Ok(()) + } +} diff --git a/backend/src/tasks/send_monthly_digest.rs b/backend/src/tasks/send_monthly_digest.rs new file mode 100644 index 0000000..26e3fd7 --- /dev/null +++ b/backend/src/tasks/send_monthly_digest.rs @@ -0,0 +1,26 @@ +use loco_rs::prelude::*; + +use crate::services::subscriptions; + +pub struct SendMonthlyDigest; + +#[async_trait] +impl Task for SendMonthlyDigest { + fn task(&self) -> TaskInfo { + TaskInfo { + name: "send_monthly_digest".to_string(), + detail: "queue monthly digest notifications".to_string(), + } + } + + async fn run(&self, app_context: &AppContext, _vars: &task::Vars) -> Result<()> { + let summary = subscriptions::send_digest(app_context, "monthly").await?; + tracing::info!( + "send_monthly_digest queued={} skipped={} posts={}", + summary.queued, + summary.skipped, + summary.post_count + ); + Ok(()) + } +} diff --git a/backend/src/tasks/send_weekly_digest.rs b/backend/src/tasks/send_weekly_digest.rs new file mode 100644 index 0000000..717d27c --- /dev/null +++ b/backend/src/tasks/send_weekly_digest.rs @@ -0,0 +1,26 @@ +use loco_rs::prelude::*; + +use crate::services::subscriptions; + +pub struct SendWeeklyDigest; + +#[async_trait] +impl Task for SendWeeklyDigest { + fn task(&self) -> TaskInfo { + TaskInfo { + name: "send_weekly_digest".to_string(), + detail: "queue weekly digest notifications".to_string(), + } + } + + async fn run(&self, app_context: &AppContext, _vars: &task::Vars) -> Result<()> { + let summary = subscriptions::send_digest(app_context, "weekly").await?; + tracing::info!( + "send_weekly_digest queued={} skipped={} posts={}", + summary.queued, + summary.skipped, + summary.post_count + ); + Ok(()) + } +} diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs index acb5733..9df2b47 100644 --- a/backend/src/workers/mod.rs +++ b/backend/src/workers/mod.rs @@ -1 +1,2 @@ pub mod downloader; +pub mod notification_delivery; diff --git a/backend/src/workers/notification_delivery.rs b/backend/src/workers/notification_delivery.rs new file mode 100644 index 0000000..9542df9 --- /dev/null +++ b/backend/src/workers/notification_delivery.rs @@ -0,0 +1,28 @@ +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::services::subscriptions; + +pub struct NotificationDeliveryWorker { + pub ctx: AppContext, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NotificationDeliveryWorkerArgs { + pub delivery_id: i32, +} + +#[async_trait] +impl BackgroundWorker for NotificationDeliveryWorker { + fn build(ctx: &AppContext) -> Self { + Self { ctx: ctx.clone() } + } + + fn tags() -> Vec { + vec!["notifications".to_string()] + } + + async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> { + subscriptions::process_delivery(&self.ctx, args.delivery_id).await + } +} diff --git a/backend/target-codex-ai-fix/.rustc_info.json b/backend/target-codex-ai-fix/.rustc_info.json new file mode 100644 index 0000000..18c0488 --- /dev/null +++ b/backend/target-codex-ai-fix/.rustc_info.json @@ -0,0 +1 @@ +{"rustc_fingerprint":10734737548331824535,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.92.0 (ded5c06cf 2025-12-08)\nbinary: rustc\ncommit-hash: ded5c06cf21d2b93bffd5d884aa6e96934ee4234\ncommit-date: 2025-12-08\nhost: x86_64-pc-windows-msvc\nrelease: 1.92.0\nLLVM version: 21.1.3\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\Andorid\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}} \ No newline at end of file diff --git a/backend/target-codex-ai-fix/CACHEDIR.TAG b/backend/target-codex-ai-fix/CACHEDIR.TAG new file mode 100644 index 0000000..20d7c31 --- /dev/null +++ b/backend/target-codex-ai-fix/CACHEDIR.TAG @@ -0,0 +1,3 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by cargo. +# For information about cache directory tags see https://bford.info/cachedir/ diff --git a/deploy/caddy/Caddyfile.tohka.example b/deploy/caddy/Caddyfile.tohka.example new file mode 100644 index 0000000..bfd3b23 --- /dev/null +++ b/deploy/caddy/Caddyfile.tohka.example @@ -0,0 +1,83 @@ +# termi-blog / tohka Caddyfile 模板 +# +# 说明: +# - 这是“参考模板”,不是已部署配置 +# - 适合 tohka 上“宿主机大 Caddyfile -> localhost 端口 -> Docker 容器”的模式 +# - 默认假设: +# - frontend 绑定到 localhost:4321 +# - admin 绑定到 localhost:4322 +# - backend 绑定到 localhost:5150 + + +# ----------------------------- +# 方案 A:推荐,子域名分流 +# ----------------------------- + +blog.init.cool { + import common + reverse_proxy http://localhost:4321 +} + +admin.blog.init.cool { + import common + # 推荐:admin 域名整体走 TinyAuth / Pocket ID 保护 + # tohka 现成片段会转发: + # Remote-User / Remote-Name / Remote-Email / Remote-Groups + import tinyauth + + # admin 静态资源与后台 API 都走同一受保护域名 + # 如果 backend 开启了 TERMI_ADMIN_PROXY_SHARED_SECRET, + # 记得在转发 /api 到 backend 时补一个共享密钥头,避免直接伪造 Remote-User。 + handle /api/* { + reverse_proxy http://localhost:5150 { + header_up X-Termi-Proxy-Secret {$TERMI_ADMIN_PROXY_SHARED_SECRET} + } + } + + handle { + reverse_proxy http://localhost:4322 + } +} + +# 前台公开 API 可单独暴露(评论 / 搜索 / AI 问答等) +api.blog.init.cool { + import common + reverse_proxy http://localhost:5150 +} + + +# ----------------------------- +# 方案 B:单域名 + 路径分流 +# ----------------------------- +# +# 注意: +# 1. /admin 方案要求 admin 构建时设置: +# VITE_ADMIN_BASENAME=/admin +# 2. /admin 使用 handle_path,进入 admin 容器前会去掉 /admin 前缀 +# 3. /api 不要用 handle_path;backend 当前路由本身就包含 /api 前缀 +# 4. 如果 /admin 也要调用受保护 API,需要同时把 /api 接到 backend 并加 tinyauth + +init.cool { + import common + + handle_path /admin* { + import tinyauth + reverse_proxy http://localhost:4322 + } + + handle /api* { + import tinyauth + reverse_proxy http://localhost:5150 { + header_up X-Termi-Proxy-Secret {$TERMI_ADMIN_PROXY_SHARED_SECRET} + } + } + + handle { + reverse_proxy http://localhost:4321 + } +} + +# 部署时 backend 记得配套: +# TERMI_ADMIN_TRUST_PROXY_AUTH=true +# TERMI_ADMIN_LOCAL_LOGIN_ENABLED=false +# TERMI_ADMIN_PROXY_SHARED_SECRET=<随机长字符串> diff --git a/deploy/caddy/Caddyfile.tohka.production.example b/deploy/caddy/Caddyfile.tohka.production.example new file mode 100644 index 0000000..7b874db --- /dev/null +++ b/deploy/caddy/Caddyfile.tohka.production.example @@ -0,0 +1,43 @@ +# 直接粘到 tohka 宿主机大 Caddyfile 里的推荐块 +# 前提: +# 1. 已存在 import common / import tinyauth 片段 +# 2. docker compose 使用 compose.tohka.override.yml,把容器端口绑到 127.0.0.1 +# 3. 环境里已设置: +# TERMI_ADMIN_PROXY_SHARED_SECRET=<随机长字符串> + +blog.init.cool { + import common + reverse_proxy http://127.0.0.1:4321 +} + +admin.blog.init.cool { + import common + import tinyauth + + # 后台 API:受 TinyAuth 保护,并附带后端共享密钥 + handle /api/* { + reverse_proxy http://127.0.0.1:5150 { + header_up X-Termi-Proxy-Secret {$TERMI_ADMIN_PROXY_SHARED_SECRET} + } + } + + # 后台静态资源 / SPA + handle { + reverse_proxy http://127.0.0.1:4322 + } +} + +# 前台公开 API(评论 / 搜索 / AI 问答 / 订阅管理) +api.blog.init.cool { + import common + reverse_proxy http://127.0.0.1:5150 +} + +# 对应 deploy/docker/.env 关键项: +# APP_BASE_URL=https://admin.blog.init.cool +# PUBLIC_API_BASE_URL=https://api.blog.init.cool +# ADMIN_API_BASE_URL=https://admin.blog.init.cool +# ADMIN_FRONTEND_BASE_URL=https://blog.init.cool +# TERMI_ADMIN_TRUST_PROXY_AUTH=true +# TERMI_ADMIN_LOCAL_LOGIN_ENABLED=false +# TERMI_ADMIN_PROXY_SHARED_SECRET=<随机长字符串> diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example new file mode 100644 index 0000000..7189e26 --- /dev/null +++ b/deploy/docker/.env.example @@ -0,0 +1,62 @@ +# Compose runtime variables (package image deployment) +BACKEND_PORT=5150 +FRONTEND_PORT=4321 +ADMIN_PORT=4322 + +# frontend SSR 服务端访问 backend 用这个内部地址(compose 默认可直接使用) +INTERNAL_API_BASE_URL=http://backend:5150/api + +# 浏览器里评论 / AI 问答 / 搜索等请求优先读取这个公开 API 地址。 +# 如果留空,frontend 会在生产环境按“当前访问主机 + :5150/api”回退。 +# 走反向代理时建议显式设置,例如: +# PUBLIC_API_BASE_URL=https://your-frontend.example.com/api +PUBLIC_API_BASE_URL= + +# 前台 /_img 图片优化端点默认只放行“当前站点同域”图片。 +# 如果你的文章封面或对象存储图片来自额外 CDN / R2 公网域名, +# 可以在这里填逗号分隔的 host 列表,例如: +# PUBLIC_IMAGE_ALLOWED_HOSTS=cdn.example.com,pub-xxxx.r2.dev +PUBLIC_IMAGE_ALLOWED_HOSTS= + +# admin 浏览器请求 backend API 优先读取这个公开地址。 +# 如果留空,admin 会在生产环境按“当前访问主机 + :5150”回退。 +# 如果你采用推荐方案(admin 域名同域转发 /api 到 backend), +# 建议直接填后台域名 origin,例如: +# ADMIN_API_BASE_URL=https://admin.example.com +ADMIN_API_BASE_URL= + +# admin 页面里的“打开前台 / AI 问答 / 文章预览”链接优先读取这个运行时变量。 +# 如果你不是直接把前台暴露在 http://:4321,而是走独立域名 / HTTPS / 反向代理, +# 建议设置为正式前台地址,例如: +# ADMIN_FRONTEND_BASE_URL=https://your-frontend.example.com +ADMIN_FRONTEND_BASE_URL= + +APP_BASE_URL=http://localhost:5150 +DATABASE_URL=postgres://:@:5432/termi_api +REDIS_URL=redis://:6379 +JWT_SECRET=change-me-before-production +JWT_EXPIRATION_SECONDS=604800 +RUST_LOG=info + +# 邮件确认 / 通知投递需要 SMTP +SMTP_ENABLE=false +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASSWORD= +SMTP_HELLO_NAME= + +# 启用 TinyAuth / Pocket ID / Caddy forward_auth 时建议: +# - TERMI_ADMIN_TRUST_PROXY_AUTH=true +# - TERMI_ADMIN_LOCAL_LOGIN_ENABLED=false +# - 额外配置一个共享密钥,并在 Caddy 转发 /api 到 backend 时附带: +# X-Termi-Proxy-Secret: {$TERMI_ADMIN_PROXY_SHARED_SECRET} +TERMI_ADMIN_TRUST_PROXY_AUTH=false +TERMI_ADMIN_LOCAL_LOGIN_ENABLED=true +TERMI_ADMIN_PROXY_SHARED_SECRET= + +# Optional: override package tags if needed +BACKEND_IMAGE=git.init.cool/cool/termi-astro-backend:latest +FRONTEND_IMAGE=git.init.cool/cool/termi-astro-frontend:latest +ADMIN_IMAGE=git.init.cool/cool/termi-astro-admin:latest diff --git a/deploy/docker/ARCHITECTURE.md b/deploy/docker/ARCHITECTURE.md new file mode 100644 index 0000000..6f964c8 --- /dev/null +++ b/deploy/docker/ARCHITECTURE.md @@ -0,0 +1,224 @@ +# Docker / 反代架构说明 + +本文记录当前项目在 `tohka` 这类宿主机上的推荐部署结构,以及为什么会同时出现: + +- 宿主机层的 **Caddy** +- `admin` 容器内的 **Nginx** + +## 1. 总体分层 + +推荐生产结构: + +```text +Internet + -> Host Caddy (:80 / :443) + -> frontend container (Astro SSR / Node :4321) + -> admin container (Nginx :80, 静态 SPA) + -> backend container (Loco.rs API :5150) + -> backend-worker container (Loco.rs worker / Redis queue) +``` + +职责划分: + +- **Host Caddy** + - 统一接收公网流量 + - 处理域名、HTTPS、证书续签 + - 反向代理到各个内部容器 +- **frontend** + - Astro SSR(Node) 应用 + - 不是纯静态站,所以容器内直接运行 Node 服务即可 +- **admin** + - React/Vite 打包后的纯静态 SPA + - 容器内使用 Nginx 提供静态文件 + - 生产推荐前面接 TinyAuth / Pocket ID 做 SSO +- **backend** + - API、后台鉴权、审计、版本历史、订阅投递 +- **backend-worker** + - 消费 Redis 队列 + - 负责通知异步投递、失败重试、digest 任务触发后的实际发送 + +## 2. 为什么上层已经有 Caddy,admin 容器里还要 Nginx? + +这两层并不冲突,职责不同: + +- **Caddy** 是入口网关 +- **Nginx** 是 admin 容器内部的静态文件服务器 + +也就是说: + +```text +Browser + -> Caddy + -> admin nginx + -> /usr/share/nginx/html +``` + +这样做的好处: + +- admin 镜像本身自带可用的静态文件服务能力 +- 宿主机层仍然保留统一的域名 / HTTPS / 路由管理 +- admin 作为独立前端,可以单独构建、单独发布 + +## 2.2 为什么现在多了 `backend-worker`? + +因为当前通知系统已经改成: + +```text +backend (web) + -> 写入 notification_deliveries + -> enqueue 到 Redis +backend-worker + -> 消费队列 + -> 发送 email / webhook / discord / telegram / ntfy +``` + +如果只启动 `backend` 而没有 `backend-worker`,通知会入队但没人消费。 + +## 2.1 推荐的后台认证链路 + +当前最推荐: + +```text +Browser + -> Caddy (import tinyauth) + -> TinyAuth + -> Pocket ID (OIDC) + -> admin nginx / backend API +``` + +关键点: + +- admin 页面和它调用的 `/api/*` 建议走 **同一个受保护后台域名** +- Caddy 使用 `forward_auth` +- backend 开启 `TERMI_ADMIN_TRUST_PROXY_AUTH=true` +- 生产推荐再配 `TERMI_ADMIN_PROXY_SHARED_SECRET=<随机长字符串>` +- Caddy 在 `/api/*` 反代到 backend 时补 `X-Termi-Proxy-Secret` +- backend 读取 TinyAuth 转发的 `Remote-User / Remote-Email / Remote-Groups` +- 本地开发可以保留 `TERMI_ADMIN_LOCAL_LOGIN_ENABLED=true` + +## 3. 为什么 frontend 不使用同样的 Nginx 模式? + +因为当前 `frontend` 是 **Astro SSR**: + +- `output: 'server'` +- `@astrojs/node` standalone + +它需要在请求期执行服务端逻辑,因此更适合: + +```text +Caddy -> frontend Node server +``` + +而不是先打成纯静态文件再由 Nginx 托管。 + +## 4. admin 容器内 Nginx 当前负责什么? + +当前 `admin/nginx.conf` 主要负责: + +- SPA fallback:`try_files ... /index.html` +- `assets/` 长缓存(hash 资源可 `immutable`) +- `index.html` / `runtime-config.js` 禁缓存,避免配置或入口文件陈旧 +- `gzip` 压缩 +- 基础安全响应头 +- `/healthz` 健康检查入口 + +### 为什么 `runtime-config.js` 要禁缓存? + +因为 admin 现在支持运行时环境变量注入,例如: + +- `ADMIN_API_BASE_URL` +- `ADMIN_FRONTEND_BASE_URL` + +容器启动时会生成 `runtime-config.js`。 +如果它被强缓存,改完环境变量重启容器后,浏览器可能仍然读到旧地址。 + +## 5. 为什么没有在 admin 容器里启用 Brotli? + +当前基础镜像是官方 `nginx:alpine`。 +这个镜像默认不一定带 Brotli 模块,所以这里先启用通用的 `gzip`。 + +如果后续确实需要 Brotli,有两个常见做法: + +- 让宿主机层的 Caddy 统一负责压缩 +- 改用带 Brotli 模块的自定义 Nginx 镜像 + +对当前项目而言,优先让 **宿主机 Caddy 做统一公网入口**,admin 容器内部只负责稳妥地提供静态文件,是更简单的方案。 + +## 6. 推荐的 tohka 思路 + +如果 `tohka` 上已经有一个统一的大 Caddyfile,推荐继续保持: + +- Caddy 统一暴露 `80/443` +- `frontend/admin/backend` 只走内网端口 +- 不把数据库 / Redis 直接暴露到公网 +- backend 如果启用了代理 SSO,不要再把 `:5150` 直接开放给公网 + +仓库里已经额外提供: + +- `deploy/docker/compose.tohka.override.yml` + +用它叠加 `compose.package.yml` 后,会把: + +- `frontend:4321` +- `admin:4322` +- `backend:5150` + +都只绑定到 `127.0.0.1` + +## 7. 当前和配置相关的关键文件 + +- 宿主机入口反代:`tohka` 上的大 Caddyfile +- admin 静态服务:`admin/nginx.conf` +- admin 镜像:`admin/Dockerfile` +- Caddy 参考模板:`deploy/caddy/Caddyfile.tohka.example` +- compose 示例:`deploy/docker/compose.package.yml` +- tohka override:`deploy/docker/compose.tohka.override.yml` +- OIDC / Pocket ID 落地:`deploy/docker/TOHKA_POCKET_ID.md` +- 运行时环境示例:`deploy/docker/.env.example` + +## 8. Caddy 路由推荐 + +默认更推荐: + +- `blog.init.cool` -> frontend +- `admin.blog.init.cool` -> admin + backend(`/api/*`) +- `api.blog.init.cool` -> backend + +这样最省心,也最不容易碰到路径前缀、资源基路径、Cookie Path 等问题。 + +如果一定要用: + +- `init.cool/admin` +- `init.cool/api` + +也可以,但 `admin` 需要在构建时设置: + +- `VITE_ADMIN_BASENAME=/admin` + +对应模板见: + +- `deploy/caddy/Caddyfile.tohka.example` + +## 9. 备份 / 恢复入口 + +当前仓库内已经补了: + +- `deploy/scripts/backup/backup-postgres.sh` +- `deploy/scripts/backup/backup-markdown.sh` +- `deploy/scripts/backup/backup-media.sh` +- `deploy/scripts/backup/restore-postgres.sh` +- `deploy/scripts/backup/restore-markdown.sh` +- `deploy/scripts/backup/restore-media.sh` +- `deploy/docker/BACKUP_AND_RECOVERY.md` + +建议把这些接进生产上的 cron / systemd timer,而不是只停留在仓库里。 + +## 10. 健康检查与启动顺序 + +当前推荐闭环: + +- backend 启动时先自动跑 migration +- backend 提供 `/healthz` +- frontend 提供 `/healthz` +- admin 由 Nginx 提供 `/healthz` +- compose 中 frontend / admin / backend-worker 都依赖 backend healthy diff --git a/deploy/docker/BACKUP_AND_RECOVERY.md b/deploy/docker/BACKUP_AND_RECOVERY.md new file mode 100644 index 0000000..7830f18 --- /dev/null +++ b/deploy/docker/BACKUP_AND_RECOVERY.md @@ -0,0 +1,148 @@ +# 备份与恢复说明 + +这套博客现在已经有: + +- PostgreSQL 数据库 +- Markdown 原文内容 +- 媒体文件 / 对象存储 +- 版本历史 / 审计日志 / 订阅数据 + +所以生产上最重要的不是再多一两个功能,而是**出事后能不能快速恢复**。 + +## 1. 建议的最小备份策略 + +### PostgreSQL +- **频率**:每天至少 1 次;高频站点建议每 6~12 小时 1 次 +- **工具**:`pg_dump --format=custom` +- **脚本**:`deploy/scripts/backup/backup-postgres.sh` + +### Markdown 原文 +- **频率**:每次发布后 + 每天定时 1 次 +- **脚本**:`deploy/scripts/backup/backup-markdown.sh` +- **原因**:Markdown 是内容源,恢复速度最快 + +### 媒体文件 +- 如果是本地目录:打包归档 +- 如果是 R2 / S3 / MinIO:定时 `aws s3 sync` +- **脚本**:`deploy/scripts/backup/backup-media.sh` + +## 2. 一键脚本 + +```bash +# 全量备份 +./deploy/scripts/backup/backup-all.sh + +# 单独备份数据库 +DATABASE_URL=postgres://... ./deploy/scripts/backup/backup-postgres.sh + +# 单独备份 Markdown +MARKDOWN_SOURCE_DIR=./backend/content/posts ./deploy/scripts/backup/backup-markdown.sh + +# 单独备份媒体(本地目录) +MEDIA_SOURCE_DIR=./uploads ./deploy/scripts/backup/backup-media.sh + +# 单独备份媒体(R2 / S3) +MEDIA_S3_SOURCE=s3://bucket-name ./deploy/scripts/backup/backup-media.sh +``` + +## 3. 恢复步骤 + +### 恢复 PostgreSQL + +```bash +DATABASE_URL=postgres://... ./deploy/scripts/backup/restore-postgres.sh ./backups/postgres/latest.dump +``` + +### 恢复 Markdown + +```bash +MARKDOWN_TARGET_DIR=./backend/content/posts ./deploy/scripts/backup/restore-markdown.sh ./backups/markdown/latest.tar.gz +``` + +### 恢复媒体 + +```bash +# 本地目录方式 +MEDIA_TARGET_DIR=./uploads ./deploy/scripts/backup/restore-media.sh ./backups/media/latest.tar.gz + +# R2 / S3 方式 +MEDIA_S3_TARGET=s3://bucket-name ./deploy/scripts/backup/restore-media.sh ./backups/media/media-20260331T120000Z +``` + +## 4. 推荐的生产 Cron 示例 + +```cron +# 每天 03:10 备份 PostgreSQL +10 3 * * * cd /opt/termi-astro && DATABASE_URL=postgres://... ./deploy/scripts/backup/backup-postgres.sh >> /var/log/termi-backup.log 2>&1 + +# 每天 03:25 备份 Markdown +25 3 * * * cd /opt/termi-astro && MARKDOWN_SOURCE_DIR=./backend/content/posts ./deploy/scripts/backup/backup-markdown.sh >> /var/log/termi-backup.log 2>&1 + +# 每天 03:40 备份媒体 +40 3 * * * cd /opt/termi-astro && MEDIA_S3_SOURCE=s3://bucket-name ./deploy/scripts/backup/backup-media.sh >> /var/log/termi-backup.log 2>&1 + +# 每天 04:15 清理过期备份 +15 4 * * * cd /opt/termi-astro && ./deploy/scripts/backup/prune-backups.sh >> /var/log/termi-backup.log 2>&1 + +# 每天 04:40 异地同步 +40 4 * * * cd /opt/termi-astro && OFFSITE_TARGET=/mnt/offsite/termi-astro-backups ./deploy/scripts/backup/sync-backups-offsite.sh >> /var/log/termi-backup.log 2>&1 +``` + +## 5. 建议你们再加一层异地备份 + +仅仅把备份留在同一台服务器上不够。 + +至少保证: +- 主机本地保留最近 7~14 天 +- 再同步一份到另一块存储 / 另一台主机 / 对象存储冷备桶 + +## 6. 恢复演练建议 + +建议每个月至少做 1 次演练: + +1. 用最新数据库备份恢复到临时环境 +2. 用 Markdown 备份恢复内容目录 +3. 用媒体备份恢复对象 +4. 校验: + - 首页可打开 + - 文章详情可打开 + - 图片可访问 + - 后台可登录 + - 审计 / 版本 / 订阅表存在数据 + +也可以直接用恢复演练脚本: + +```bash +DATABASE_URL=postgres://... \ +POSTGRES_BACKUP=./backups/postgres/latest.dump \ +MARKDOWN_BACKUP=./backups/markdown/latest.tar.gz \ +MEDIA_BACKUP=./backups/media/latest.tar.gz \ +./deploy/scripts/backup/verify-restore.sh +``` + +## 7. 当前架构下的恢复优先级 + +发生事故时建议按这个顺序: + +1. 恢复数据库 +2. 恢复 Markdown 原文 +3. 恢复媒体资源 +4. 启动 backend / frontend / admin +5. 进入后台检查: + - 审计日志 + - 文章版本历史 + - 订阅目标与最近投递 + +## 8. 说明 + +这些脚本是**仓库内参考实现**,没有在你们生产机上自动执行。 +正式上线前请按你们实际目录、R2/S3 桶、数据库连接串、cron 规范再过一遍。 + +另外仓库里已经提供: + +- `deploy/systemd/README.md` +- `deploy/systemd/termi-backup-all.timer` +- `deploy/systemd/termi-backup-prune.timer` +- `deploy/systemd/termi-backup-offsite-sync.timer` + +如果宿主机使用 systemd,建议优先启用 timer,而不是只靠手工执行。 diff --git a/deploy/docker/README.md b/deploy/docker/README.md new file mode 100644 index 0000000..2cc7b1b --- /dev/null +++ b/deploy/docker/README.md @@ -0,0 +1,173 @@ +# Docker 部署(Package 镜像) + +补充架构说明见: + +- `deploy/docker/ARCHITECTURE.md` +- `deploy/caddy/Caddyfile.tohka.example` +- `deploy/caddy/Caddyfile.tohka.production.example` +- `deploy/docker/TOHKA_POCKET_ID.md` +- `deploy/docker/TOHKA_DEPLOY_RUNBOOK.md` +- `deploy/docker/config.yaml.example` +- `deploy/docker/BACKUP_AND_RECOVERY.md` + +## 1) 准备主配置文件(config.yaml) + +现在推荐把: + +- `deploy/docker/config.yaml` + +作为部署主配置源;`deploy/docker/.env` 改为脚本生成产物。 + +先复制模板: + +```bash +cp deploy/docker/config.yaml.example deploy/docker/config.yaml +``` + +然后填写至少这些核心项: + +- `compose_env.DATABASE_URL` +- `compose_env.REDIS_URL` +- `compose_env.JWT_SECRET` +- 邮件确认 / 邮件通知上线前,请同时补齐 SMTP 配置 + +填完后执行: + +```bash +python deploy/scripts/render_compose_env.py \ + --input deploy/docker/config.yaml \ + --output deploy/docker/.env +``` + +如果你们内部喜欢先审 YAML,再部署,这就是现在的推荐流程。 + +建议在 `config.yaml -> compose_env` 下同时检查这些运行时变量: + +- `INTERNAL_API_BASE_URL`:frontend SSR 容器访问 backend 用,compose 默认推荐 `http://backend:5150/api` +- `PUBLIC_API_BASE_URL`:浏览器访问 backend API 用;留空时前台会回退到“当前主机 + `:5150/api`” +- `PUBLIC_IMAGE_ALLOWED_HOSTS`:前台 `/_img` 图片优化端点允许的额外图片 host(逗号分隔) +- `ADMIN_API_BASE_URL`:admin 浏览器访问 backend API 用;留空时后台会回退到“当前主机 + `:5150`” +- `ADMIN_FRONTEND_BASE_URL`:admin 里“打开前台 / 问答页 / 文章页预览”跳转用 +- `TERMI_ADMIN_TRUST_PROXY_AUTH`:是否信任前置代理(如 Caddy + TinyAuth)注入的后台认证头 +- `TERMI_ADMIN_LOCAL_LOGIN_ENABLED`:是否保留本地账号密码登录兜底 +- `TERMI_ADMIN_PROXY_SHARED_SECRET`:代理 SSO 共享密钥;建议和 Caddy 的 `X-Termi-Proxy-Secret` 配套使用 +- `SMTP_ENABLE / SMTP_HOST / SMTP_PORT / SMTP_SECURE / SMTP_USER / SMTP_PASSWORD / SMTP_HELLO_NAME`:订阅确认和邮件通知需要 + +例如: + +```yaml +compose_env: + PUBLIC_API_BASE_URL: https://api.blog.init.cool + ADMIN_API_BASE_URL: https://admin.blog.init.cool + ADMIN_FRONTEND_BASE_URL: https://blog.init.cool + TERMI_ADMIN_TRUST_PROXY_AUTH: true + TERMI_ADMIN_LOCAL_LOGIN_ENABLED: false + TERMI_ADMIN_PROXY_SHARED_SECRET: replace-with-a-long-random-secret +``` + +> 这些值最终会被渲染成 `deploy/docker/.env`,再由 `compose.package.yml` 读取。 +> 如果镜像构建期也注入了 `FRONTEND_PUBLIC_API_BASE_URL` / `ADMIN_VITE_API_BASE` / `ADMIN_VITE_FRONTEND_BASE_URL`,则运行时变量优先级更高。 + +## 2) 启动 + +在仓库根目录执行: + +```bash +python deploy/scripts/render_compose_env.py \ + --input deploy/docker/config.yaml \ + --output deploy/docker/.env + +docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env up -d +``` + +如果是在 `tohka` 上并且前面接宿主机 Caddy,推荐改用: + +```bash +python deploy/scripts/render_compose_env.py \ + --input deploy/docker/config.yaml \ + --output deploy/docker/.env + +docker compose \ + -f deploy/docker/compose.package.yml \ + -f deploy/docker/compose.tohka.override.yml \ + --env-file deploy/docker/.env up -d +``` + +当前 compose 推荐一起启动 4 个容器: + +- `backend`:API / migration / `/healthz` +- `backend-worker`:Redis 队列消费者(通知异步投递、失败重试) +- `frontend`:Astro SSR(Node) +- `admin`:静态 SPA + Nginx + +`compose.tohka.override.yml` 额外会把三个对外端口绑到 `127.0.0.1`,避免把 backend / admin 直接暴露到公网。 + +## 3) 更新镜像后重拉 + +```bash +docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env pull +docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env up -d +``` + +## 4) 问答记录(2026-03-31) + +### Q1: 为什么前台容器是 `4321` 端口? +A: 前台是 **Astro SSR(Node)**(`output: 'server'`),容器内运行 Node 服务(`dist/server/entry.mjs`),所以需要监听内部端口 `4321`。 + +### Q2: 两个前端都能做成纯静态 + 反代吗? +A: +- `admin` 可以,当前就是静态资源由 Nginx 提供。 +- `frontend` 当前不行(未做纯静态改造):项目里使用了 `prerender = false`、`Astro.request`、`Astro.cookies` 等请求期逻辑。 + +### Q3: SSR 和 CSR 怎么选? +A: 当前站点对外内容页优先 SEO 与首屏可见性,保留 SSR 更稳;后台管理台继续 CSR/静态站即可。 + +### Q4: 生产推荐端口设计是什么? +A: 推荐前置 Caddy/Nginx 统一暴露 `80/443`,`frontend:4321` / `backend:5150` / `admin:80` 仅走内网。 +当前 `compose.package.yml` 属于直连端口版,便于快速部署与联调。 +另外因为通知已经走异步队列,生产务必同时启动 `backend-worker`。 + +### Q5: 为什么 compose 里没看到 `ADMIN_VITE_FRONTEND_BASE_URL`? +A: +- 现在 compose 运行时可以直接设置 `ADMIN_FRONTEND_BASE_URL`,用于覆盖 admin 里所有前台跳转链接。 +- `ADMIN_VITE_FRONTEND_BASE_URL` 仍然可以作为 **镜像构建期默认值** 保留在 CI / workflow 里。 +- 如果两者都存在,**运行时 `ADMIN_FRONTEND_BASE_URL` 优先**。 + +### Q6: 前台 / 后台为什么拆成 public/internal 两种 API 地址? +A: +- `frontend` 是 Astro SSR,服务端渲染请求 backend 时更适合走内网地址(如 `http://backend:5150/api`)。 +- 但浏览器里的评论、问答、搜索请求必须走用户可访问的公开地址。 +- `admin` 是纯静态 SPA,也只能使用浏览器可访问的公开 API 地址。 +- 所以现在区分为: + - `INTERNAL_API_BASE_URL`:frontend SSR 内部访问 + - `PUBLIC_API_BASE_URL`:前台浏览器访问 + - `ADMIN_API_BASE_URL`:admin 浏览器访问 + +### Q7: 现在后台 OIDC / Pocket ID 怎么接? +A: +- 推荐直接复用 tohka 上现成的 **TinyAuth + Pocket ID**。 +- Caddy 在 `admin` 域名入口加 `import tinyauth`,并把 `/api/*` 同域转发到 backend。 +- backend 开启: + - `TERMI_ADMIN_TRUST_PROXY_AUTH=true` + - `TERMI_ADMIN_LOCAL_LOGIN_ENABLED=false` + - `TERMI_ADMIN_PROXY_SHARED_SECRET=<随机长字符串>` +- Caddy 在 `/api/*` -> backend 时补: + - `X-Termi-Proxy-Secret: {$TERMI_ADMIN_PROXY_SHARED_SECRET}` +- backend 会信任 TinyAuth 转发的: + - `Remote-User` + - `Remote-Email` + - `Remote-Groups` +- 本地开发仍可保留内置账号密码登录。 + +### Q8: 备份现在放在哪看? +A: +- 参考脚本:`deploy/scripts/backup/` +- 恢复文档:`deploy/docker/BACKUP_AND_RECOVERY.md` + +### Q9: 现在健康检查和 migration 怎么处理? +A: +- `backend` 镜像启动时会先执行 `db migrate` +- `backend` 提供 `/healthz` +- `frontend` 提供 `/healthz` +- `admin` 继续由 Nginx 提供 `/healthz` +- compose 现在使用 `depends_on.condition: service_healthy` diff --git a/deploy/docker/TOHKA_DEPLOY_RUNBOOK.md b/deploy/docker/TOHKA_DEPLOY_RUNBOOK.md new file mode 100644 index 0000000..31edc7f --- /dev/null +++ b/deploy/docker/TOHKA_DEPLOY_RUNBOOK.md @@ -0,0 +1,124 @@ +# tohka 最终部署操作手册(config.yaml 版) + +这份手册按“准备 -> 渲染配置 -> 启动 -> 接 Caddy -> 启 timers -> 验证”执行。 + +## 1. 准备文件 + +先复制配置模板: + +```bash +cp deploy/docker/config.yaml.example deploy/docker/config.yaml +``` + +再按生产实际填写: + +- 域名 +- Postgres / Redis 地址 +- JWT secret +- SMTP +- TinyAuth / Pocket ID 共享密钥 +- 镜像 tag + +主配置源是: + +- `deploy/docker/config.yaml` + +## 2. 渲染 `.env` + +```bash +python deploy/scripts/render_compose_env.py \ + --input deploy/docker/config.yaml \ + --output deploy/docker/.env +``` + +如果只是想预览,不落盘: + +```bash +python deploy/scripts/render_compose_env.py \ + --input deploy/docker/config.yaml \ + --stdout +``` + +## 3. 启动容器 + +```bash +docker compose \ + -f deploy/docker/compose.package.yml \ + -f deploy/docker/compose.tohka.override.yml \ + --env-file deploy/docker/.env up -d +``` + +查看状态: + +```bash +docker compose \ + -f deploy/docker/compose.package.yml \ + -f deploy/docker/compose.tohka.override.yml \ + --env-file deploy/docker/.env ps +``` + +## 4. 接宿主机 Caddy + +直接参考: + +- `deploy/caddy/Caddyfile.tohka.production.example` + +建议域名: + +- `blog.init.cool` +- `admin.blog.init.cool` +- `api.blog.init.cool` + +关键点: + +- `admin.blog.init.cool` 整体挂 `import tinyauth` +- `admin.blog.init.cool/api/*` 转 backend 时带: + - `X-Termi-Proxy-Secret {$TERMI_ADMIN_PROXY_SHARED_SECRET}` + +## 5. 启用 systemd timers + +```bash +sudo cp deploy/systemd/*.service /etc/systemd/system/ +sudo cp deploy/systemd/*.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now termi-retry-deliveries.timer +sudo systemctl enable --now termi-weekly-digest.timer +sudo systemctl enable --now termi-monthly-digest.timer +sudo systemctl enable --now termi-backup-all.timer +sudo systemctl enable --now termi-backup-prune.timer +sudo systemctl enable --now termi-backup-offsite-sync.timer +``` + +## 6. 做首轮验证 + +至少检查: + +- `http://127.0.0.1:5150/healthz` +- `http://127.0.0.1:4321/healthz` +- `http://127.0.0.1:4322/healthz` +- `https://admin.blog.init.cool` 能正常走 Pocket ID / TinyAuth 登录 +- 订阅确认邮件能正常送达 +- 测试通知 / 周报 / 月报能正常入队并送达 + +## 7. 上线后维护动作 + +每次改 `deploy/docker/config.yaml` 后,记得重新: + +```bash +python deploy/scripts/render_compose_env.py \ + --input deploy/docker/config.yaml \ + --output deploy/docker/.env + +docker compose \ + -f deploy/docker/compose.package.yml \ + -f deploy/docker/compose.tohka.override.yml \ + --env-file deploy/docker/.env up -d +``` + +## 8. 配套文档 + +- `deploy/docker/README.md` +- `deploy/docker/ARCHITECTURE.md` +- `deploy/docker/TOHKA_POCKET_ID.md` +- `deploy/systemd/GO_LIVE_CHECKLIST.md` +- `deploy/docker/BACKUP_AND_RECOVERY.md` diff --git a/deploy/docker/TOHKA_POCKET_ID.md b/deploy/docker/TOHKA_POCKET_ID.md new file mode 100644 index 0000000..a748873 --- /dev/null +++ b/deploy/docker/TOHKA_POCKET_ID.md @@ -0,0 +1,154 @@ +# tohka 上接入 Pocket ID / TinyAuth / Caddy 的推荐做法 + +这份文档记录当前项目在 `tohka` 上最推荐的后台保护方式: + +```text +Browser + -> Host Caddy + -> TinyAuth + -> Pocket ID (OIDC) + -> admin nginx + -> backend /api +``` + +## 1. 目标 + +实现这些效果: + +- `blog.init.cool` 对外公开,走 frontend SSR +- `admin.blog.init.cool` 整体受保护 +- admin 页面和它访问的 `/api/*` 同域 +- backend 信任 TinyAuth 注入的登录身份 +- 即使 backend 端口误暴露,也额外要求一个共享密钥头,降低伪造 `Remote-User` 风险 + +## 2. 推荐使用的 compose 方式 + +基础 compose: + +- `deploy/docker/compose.package.yml` + +在 tohka 上再叠加这个 override: + +- `deploy/docker/compose.tohka.override.yml` + +部署主配置源建议使用: + +- `deploy/docker/config.yaml` + +启动方式: + +```bash +python deploy/scripts/render_compose_env.py \ + --input deploy/docker/config.yaml \ + --output deploy/docker/.env + +docker compose \ + -f deploy/docker/compose.package.yml \ + -f deploy/docker/compose.tohka.override.yml \ + --env-file deploy/docker/.env up -d +``` + +这个 override 会做三件事: + +1. `frontend / admin / backend` 只绑定到 `127.0.0.1` +2. 默认打开 `TERMI_ADMIN_TRUST_PROXY_AUTH=true` +3. 默认关闭 `TERMI_ADMIN_LOCAL_LOGIN_ENABLED` + +## 3. `config.yaml -> compose_env` 里最关键的变量 + +至少补这些: + +现在推荐直接从下面这个模板开始: + +- `deploy/docker/config.yaml.example` + +然后复制成: + +- `deploy/docker/config.yaml` + +至少补这些: + +```yaml +compose_env: + APP_BASE_URL: https://admin.blog.init.cool + PUBLIC_API_BASE_URL: https://api.blog.init.cool + ADMIN_API_BASE_URL: https://admin.blog.init.cool + ADMIN_FRONTEND_BASE_URL: https://blog.init.cool + + TERMI_ADMIN_TRUST_PROXY_AUTH: true + TERMI_ADMIN_LOCAL_LOGIN_ENABLED: false + TERMI_ADMIN_PROXY_SHARED_SECRET: replace-with-a-long-random-secret +``` + +说明: + +- `APP_BASE_URL` 建议填后台正式地址,便于后端生成后台相关链接 +- `TERMI_ADMIN_PROXY_SHARED_SECRET` 是 backend 和 Caddy 之间约定的共享密钥 +- backend 现在会在代理 SSO 模式下检查 `X-Termi-Proxy-Secret` + +## 4. Caddy 侧应该怎么配 + +直接参考: + +- `deploy/caddy/Caddyfile.tohka.example` +- `deploy/caddy/Caddyfile.tohka.production.example` + +关键点是:admin 域名下 `/api/*` 反代到 backend 时要带上: + +```caddy +header_up X-Termi-Proxy-Secret {$TERMI_ADMIN_PROXY_SHARED_SECRET} +``` + +这样 backend 才会接受代理注入的: + +- `Remote-User` +- `Remote-Email` +- `Remote-Groups` + +## 5. 为什么要加共享密钥头 + +如果只看 `Remote-User` 这类头,而 backend 又被公网直接访问, +理论上别人可以手工伪造请求头来冒充后台用户。 + +现在的建议闭环是: + +- backend 端口只监听 `127.0.0.1` +- admin 域名入口必须经过 TinyAuth / Pocket ID +- backend 额外校验 `X-Termi-Proxy-Secret` + +这样会稳很多。 + +## 6. 后台登录页现在的行为 + +当前逻辑: + +- 如果 `TERMI_ADMIN_LOCAL_LOGIN_ENABLED=true`,仍可用本地账号密码兜底 +- 如果关闭本地登录,admin 登录页会提示“请从受保护入口进入,并重新检查会话” +- backend 会优先读取代理身份头 + +## 7. 建议的 tohka 落地顺序 + +1. 在 tohka 的大 Caddyfile 里接入 `admin.blog.init.cool` +2. 给 admin 域名启用 `import tinyauth` +3. `/api/*` 同域转发到 `localhost:5150` +4. 加上 `X-Termi-Proxy-Secret` +5. 用 `compose.tohka.override.yml` 启动容器 +6. 打开 `https://admin.blog.init.cool` +7. 在后台里确认当前 session 的 `auth_source=proxy` + +完整部署步骤可再配合: + +- `deploy/docker/TOHKA_DEPLOY_RUNBOOK.md` + +## 8. digest / retry / 备份建议一起启用 + +上线时建议连同这些一起启用: + +- `deploy/systemd/termi-retry-deliveries.timer` +- `deploy/systemd/termi-weekly-digest.timer` +- `deploy/systemd/termi-monthly-digest.timer` +- `deploy/systemd/termi-backup-all.timer` +- `deploy/systemd/termi-backup-prune.timer` +- `deploy/systemd/termi-backup-offsite-sync.timer` + +这样后台保护、通知投递、备份恢复三件事才算闭环。 diff --git a/deploy/docker/compose.package.yml b/deploy/docker/compose.package.yml new file mode 100644 index 0000000..7b67905 --- /dev/null +++ b/deploy/docker/compose.package.yml @@ -0,0 +1,80 @@ +services: + backend: + image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest} + pull_policy: always + restart: unless-stopped + environment: + PORT: 5150 + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5150} + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} + REDIS_URL: ${REDIS_URL:?REDIS_URL is required} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} + # 当前推荐把 admin 放在受保护的后台域名下(同域转发 /api 到 backend), + # 然后让 backend 信任 TinyAuth / Pocket ID 通过 Caddy 注入的认证头。 + # 如启用代理 SSO,建议同时配置 TERMI_ADMIN_PROXY_SHARED_SECRET, + # 并让 Caddy 在转发 /api 到 backend 时附带 X-Termi-Proxy-Secret。 + TERMI_ADMIN_TRUST_PROXY_AUTH: ${TERMI_ADMIN_TRUST_PROXY_AUTH:-false} + TERMI_ADMIN_LOCAL_LOGIN_ENABLED: ${TERMI_ADMIN_LOCAL_LOGIN_ENABLED:-true} + TERMI_ADMIN_PROXY_SHARED_SECRET: ${TERMI_ADMIN_PROXY_SHARED_SECRET:-} + RUST_LOG: ${RUST_LOG:-info} + ports: + # 这是“直连端口”示例;如果前面接 tohka 宿主机 Caddy, + # 推荐叠加 compose.tohka.override.yml,把 backend 只绑定到 127.0.0.1。 + - '${BACKEND_PORT:-5150}:5150' + + backend-worker: + image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest} + pull_policy: always + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + command: ['termi_api-cli', '-e', 'production', 'start', '--worker'] + environment: + PORT: 5150 + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5150} + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} + REDIS_URL: ${REDIS_URL:?REDIS_URL is required} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} + TERMI_ADMIN_TRUST_PROXY_AUTH: ${TERMI_ADMIN_TRUST_PROXY_AUTH:-false} + TERMI_ADMIN_LOCAL_LOGIN_ENABLED: ${TERMI_ADMIN_LOCAL_LOGIN_ENABLED:-true} + TERMI_ADMIN_PROXY_SHARED_SECRET: ${TERMI_ADMIN_PROXY_SHARED_SECRET:-} + RUST_LOG: ${RUST_LOG:-info} + TERMI_SKIP_MIGRATIONS: 'true' + + frontend: + image: ${FRONTEND_IMAGE:-git.init.cool/cool/termi-astro-frontend:latest} + pull_policy: always + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + environment: + # frontend 是 Astro SSR(Node): + # - INTERNAL_API_BASE_URL 给服务端渲染访问 backend 用 + # - PUBLIC_API_BASE_URL 给浏览器里的评论 / AI 问答等请求用 + # - PUBLIC_IMAGE_ALLOWED_HOSTS 给前台图片优化端点 /_img 放行额外图片域名 + INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:5150/api} + PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-} + PUBLIC_IMAGE_ALLOWED_HOSTS: ${PUBLIC_IMAGE_ALLOWED_HOSTS:-} + # frontend 是 Astro SSR(Node) 服务,容器内部监听 4321 + # 生产建议由网关统一反代,仅对外开放 80/443 + ports: + - '${FRONTEND_PORT:-4321}:4321' + + admin: + image: ${ADMIN_IMAGE:-git.init.cool/cool/termi-astro-admin:latest} + pull_policy: always + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + environment: + ADMIN_API_BASE_URL: ${ADMIN_API_BASE_URL:-} + ADMIN_FRONTEND_BASE_URL: ${ADMIN_FRONTEND_BASE_URL:-} + # admin 是静态 SPA,由 Nginx 在容器内监听 80 + # API 与“打开前台 / AI 问答 / 文章预览”这类地址都优先读取运行时环境变量 + # ADMIN_API_BASE_URL / ADMIN_FRONTEND_BASE_URL;未设置时再回退到构建期值 / 同主机默认端口 + ports: + # 如果 admin 域名由宿主机 Caddy 统一反代,推荐改成 127.0.0.1 绑定。 + - '${ADMIN_PORT:-4322}:80' diff --git a/deploy/docker/compose.tohka.override.yml b/deploy/docker/compose.tohka.override.yml new file mode 100644 index 0000000..59183ba --- /dev/null +++ b/deploy/docker/compose.tohka.override.yml @@ -0,0 +1,28 @@ +services: + # 这个 override 专门给 tohka 这种“宿主机 Caddy -> localhost 端口 -> Docker 容器”模式使用。 + # 使用方式: + # docker compose \ + # -f deploy/docker/compose.package.yml \ + # -f deploy/docker/compose.tohka.override.yml \ + # --env-file deploy/docker/.env up -d + backend: + environment: + TERMI_ADMIN_TRUST_PROXY_AUTH: ${TERMI_ADMIN_TRUST_PROXY_AUTH:-true} + TERMI_ADMIN_LOCAL_LOGIN_ENABLED: ${TERMI_ADMIN_LOCAL_LOGIN_ENABLED:-false} + TERMI_ADMIN_PROXY_SHARED_SECRET: ${TERMI_ADMIN_PROXY_SHARED_SECRET:?TERMI_ADMIN_PROXY_SHARED_SECRET is required for tohka proxy mode} + ports: + - '127.0.0.1:${BACKEND_PORT:-5150}:5150' + + backend-worker: + environment: + TERMI_ADMIN_TRUST_PROXY_AUTH: ${TERMI_ADMIN_TRUST_PROXY_AUTH:-true} + TERMI_ADMIN_LOCAL_LOGIN_ENABLED: ${TERMI_ADMIN_LOCAL_LOGIN_ENABLED:-false} + TERMI_ADMIN_PROXY_SHARED_SECRET: ${TERMI_ADMIN_PROXY_SHARED_SECRET:?TERMI_ADMIN_PROXY_SHARED_SECRET is required for tohka proxy mode} + + frontend: + ports: + - '127.0.0.1:${FRONTEND_PORT:-4321}:4321' + + admin: + ports: + - '127.0.0.1:${ADMIN_PORT:-4322}:80' diff --git a/deploy/docker/config.yaml.example b/deploy/docker/config.yaml.example new file mode 100644 index 0000000..614fbde --- /dev/null +++ b/deploy/docker/config.yaml.example @@ -0,0 +1,88 @@ +# tohka 生产部署主配置源(config.yaml) +# 使用方式: +# 1. cp deploy/docker/config.yaml.example deploy/docker/config.yaml +# 2. 按实际环境填写下面参数 +# 3. python deploy/scripts/render_compose_env.py --input deploy/docker/config.yaml --output deploy/docker/.env +# 4. docker compose -f deploy/docker/compose.package.yml -f deploy/docker/compose.tohka.override.yml --env-file deploy/docker/.env up -d + +project: + name: termi-astro + host: tohka + compose_files: + - deploy/docker/compose.package.yml + - deploy/docker/compose.tohka.override.yml + env_output: deploy/docker/.env + +# 仅做文档/运维留档;docker compose 实际读取 compose_env +meta: + blog_origin: https://blog.init.cool + admin_origin: https://admin.blog.init.cool + api_origin: https://api.blog.init.cool + pocket_id_issuer: https://id.example.com + pocket_id_client: admin.blog.init.cool + +compose_env: + BACKEND_PORT: 5150 + FRONTEND_PORT: 4321 + ADMIN_PORT: 4322 + + APP_BASE_URL: https://admin.blog.init.cool + INTERNAL_API_BASE_URL: http://backend:5150/api + PUBLIC_API_BASE_URL: https://api.blog.init.cool + ADMIN_API_BASE_URL: https://admin.blog.init.cool + ADMIN_FRONTEND_BASE_URL: https://blog.init.cool + PUBLIC_IMAGE_ALLOWED_HOSTS: cdn.example.com,pub-xxxx.r2.dev + + DATABASE_URL: postgres://termi:replace-me@postgres.internal:5432/termi_api + REDIS_URL: redis://redis.internal:6379 + JWT_SECRET: replace-with-a-long-random-secret + JWT_EXPIRATION_SECONDS: 604800 + RUST_LOG: info + + SMTP_ENABLE: true + SMTP_HOST: smtp.resend.com + SMTP_PORT: 587 + SMTP_SECURE: false + SMTP_USER: resend + SMTP_PASSWORD: replace-with-smtp-password + SMTP_HELLO_NAME: admin.blog.init.cool + + TERMI_ADMIN_TRUST_PROXY_AUTH: true + TERMI_ADMIN_LOCAL_LOGIN_ENABLED: false + TERMI_ADMIN_PROXY_SHARED_SECRET: replace-with-another-long-random-secret + + BACKEND_IMAGE: git.init.cool/cool/termi-astro-backend:latest + FRONTEND_IMAGE: git.init.cool/cool/termi-astro-frontend:latest + ADMIN_IMAGE: git.init.cool/cool/termi-astro-admin:latest + +notifications: + ntfy: + enabled: true + base_url: https://ntfy.sh + example_topic: your-team-topic + timers: + retry_deliveries: termi-retry-deliveries.timer + weekly_digest: termi-weekly-digest.timer + monthly_digest: termi-monthly-digest.timer + +backups: + offsite_target: /mnt/offsite/termi-astro-backups + local_retention_days: + postgres: 14 + markdown: 14 + media: 14 + timers: + backup_all: termi-backup-all.timer + backup_prune: termi-backup-prune.timer + backup_offsite_sync: termi-backup-offsite-sync.timer + +systemd: + repo_path: /opt/termi-astro + install_path: /etc/systemd/system + enable_timers: + - termi-retry-deliveries.timer + - termi-weekly-digest.timer + - termi-monthly-digest.timer + - termi-backup-all.timer + - termi-backup-prune.timer + - termi-backup-offsite-sync.timer diff --git a/deploy/scripts/backup/backup-all.sh b/deploy/scripts/backup/backup-all.sh new file mode 100644 index 0000000..17c2b40 --- /dev/null +++ b/deploy/scripts/backup/backup-all.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"${SCRIPT_DIR}/backup-postgres.sh" +"${SCRIPT_DIR}/backup-markdown.sh" +"${SCRIPT_DIR}/backup-media.sh" + +echo "All backup jobs finished successfully." diff --git a/deploy/scripts/backup/backup-markdown.sh b/deploy/scripts/backup/backup-markdown.sh new file mode 100644 index 0000000..8f0eff5 --- /dev/null +++ b/deploy/scripts/backup/backup-markdown.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_DIR="${MARKDOWN_SOURCE_DIR:-./backend/content/posts}" +BACKUP_DIR="${BACKUP_DIR:-./backups/markdown}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" +FILE_PATH="${BACKUP_DIR}/markdown-${TIMESTAMP}.tar.gz" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "Markdown source directory not found: ${SOURCE_DIR}" >&2 + exit 1 +fi + +mkdir -p "${BACKUP_DIR}" +tar -czf "${FILE_PATH}" -C "${SOURCE_DIR}" . +ln -sfn "$(basename "${FILE_PATH}")" "${BACKUP_DIR}/latest.tar.gz" +find "${BACKUP_DIR}" -type f -name 'markdown-*.tar.gz' -mtime +"${RETENTION_DAYS}" -delete + +echo "Markdown backup written to ${FILE_PATH}" diff --git a/deploy/scripts/backup/backup-media.sh b/deploy/scripts/backup/backup-media.sh new file mode 100644 index 0000000..dd94b2a --- /dev/null +++ b/deploy/scripts/backup/backup-media.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +BACKUP_DIR="${BACKUP_DIR:-./backups/media}" +RETENTION_DAYS="${RETENTION_DAYS:-14}" +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "${BACKUP_DIR}" + +if [[ -n "${MEDIA_S3_SOURCE:-}" ]]; then + TARGET_DIR="${BACKUP_DIR}/media-${TIMESTAMP}" + mkdir -p "${TARGET_DIR}" + aws s3 sync "${MEDIA_S3_SOURCE}" "${TARGET_DIR}" ${AWS_EXTRA_ARGS:-} + ln -sfn "$(basename "${TARGET_DIR}")" "${BACKUP_DIR}/latest" + find "${BACKUP_DIR}" -maxdepth 1 -mindepth 1 -type d -name 'media-*' -mtime +"${RETENTION_DAYS}" -exec rm -rf {} + + echo "Media backup synced from ${MEDIA_S3_SOURCE} to ${TARGET_DIR}" + exit 0 +fi + +SOURCE_DIR="${MEDIA_SOURCE_DIR:-./uploads}" +FILE_PATH="${BACKUP_DIR}/media-${TIMESTAMP}.tar.gz" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + echo "Set MEDIA_SOURCE_DIR or MEDIA_S3_SOURCE before running this script" >&2 + exit 1 +fi + +tar -czf "${FILE_PATH}" -C "${SOURCE_DIR}" . +ln -sfn "$(basename "${FILE_PATH}")" "${BACKUP_DIR}/latest.tar.gz" +find "${BACKUP_DIR}" -type f -name 'media-*.tar.gz' -mtime +"${RETENTION_DAYS}" -delete + +echo "Media backup written to ${FILE_PATH}" diff --git a/deploy/scripts/backup/backup-postgres.sh b/deploy/scripts/backup/backup-postgres.sh new file mode 100644 index 0000000..0b1f0e2 --- /dev/null +++ b/deploy/scripts/backup/backup-postgres.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +BACKUP_DIR="${BACKUP_DIR:-./backups/postgres}" +RETENTION_DAYS="${RETENTION_DAYS:-14}" +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" +FILE_PATH="${BACKUP_DIR}/postgres-${TIMESTAMP}.dump" + +if [[ -z "${DATABASE_URL:-}" ]]; then + echo "DATABASE_URL is required" >&2 + exit 1 +fi + +mkdir -p "${BACKUP_DIR}" +pg_dump --format=custom --file="${FILE_PATH}" "${DATABASE_URL}" +ln -sfn "$(basename "${FILE_PATH}")" "${BACKUP_DIR}/latest.dump" +find "${BACKUP_DIR}" -type f -name 'postgres-*.dump' -mtime +"${RETENTION_DAYS}" -delete + +echo "Postgres backup written to ${FILE_PATH}" diff --git a/deploy/scripts/backup/prune-backups.sh b/deploy/scripts/backup/prune-backups.sh new file mode 100644 index 0000000..4e94475 --- /dev/null +++ b/deploy/scripts/backup/prune-backups.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +BACKUP_ROOT="${BACKUP_ROOT:-./backups}" +POSTGRES_RETENTION_DAYS="${POSTGRES_RETENTION_DAYS:-14}" +MARKDOWN_RETENTION_DAYS="${MARKDOWN_RETENTION_DAYS:-30}" +MEDIA_RETENTION_DAYS="${MEDIA_RETENTION_DAYS:-14}" +DRY_RUN="${DRY_RUN:-false}" + +prune() { + local target_dir="$1" + local pattern="$2" + local retention_days="$3" + + if [[ ! -d "${target_dir}" ]]; then + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + find "${target_dir}" -type f -name "${pattern}" -mtime +"${retention_days}" -print + return 0 + fi + + find "${target_dir}" -type f -name "${pattern}" -mtime +"${retention_days}" -delete +} + +prune_dirs() { + local target_dir="$1" + local pattern="$2" + local retention_days="$3" + + if [[ ! -d "${target_dir}" ]]; then + return 0 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + find "${target_dir}" -maxdepth 1 -mindepth 1 -type d -name "${pattern}" -mtime +"${retention_days}" -print + return 0 + fi + + find "${target_dir}" -maxdepth 1 -mindepth 1 -type d -name "${pattern}" -mtime +"${retention_days}" -exec rm -rf {} + +} + +prune "${BACKUP_ROOT}/postgres" 'postgres-*.dump' "${POSTGRES_RETENTION_DAYS}" +prune "${BACKUP_ROOT}/markdown" 'markdown-*.tar.gz' "${MARKDOWN_RETENTION_DAYS}" +prune "${BACKUP_ROOT}/media" 'media-*.tar.gz' "${MEDIA_RETENTION_DAYS}" +prune_dirs "${BACKUP_ROOT}/media" 'media-*' "${MEDIA_RETENTION_DAYS}" + +echo "Backup pruning completed under ${BACKUP_ROOT}" diff --git a/deploy/scripts/backup/restore-markdown.sh b/deploy/scripts/backup/restore-markdown.sh new file mode 100644 index 0000000..e75157e --- /dev/null +++ b/deploy/scripts/backup/restore-markdown.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +TARGET_DIR="${MARKDOWN_TARGET_DIR:-./backend/content/posts}" +BACKUP_FILE="$1" + +if [[ ! -f "${BACKUP_FILE}" ]]; then + echo "Backup file not found: ${BACKUP_FILE}" >&2 + exit 1 +fi + +mkdir -p "${TARGET_DIR}" +rm -rf "${TARGET_DIR}"/* +tar -xzf "${BACKUP_FILE}" -C "${TARGET_DIR}" +echo "Markdown restore completed into ${TARGET_DIR}" diff --git a/deploy/scripts/backup/restore-media.sh b/deploy/scripts/backup/restore-media.sh new file mode 100644 index 0000000..764c26f --- /dev/null +++ b/deploy/scripts/backup/restore-media.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +SOURCE="$1" + +if [[ -d "${SOURCE}" ]]; then + if [[ -z "${MEDIA_S3_TARGET:-}" ]]; then + echo "MEDIA_S3_TARGET is required when restoring from a synced directory backup" >&2 + exit 1 + fi + aws s3 sync "${SOURCE}" "${MEDIA_S3_TARGET}" ${AWS_EXTRA_ARGS:-} + echo "Media restore synced to ${MEDIA_S3_TARGET}" + exit 0 +fi + +TARGET_DIR="${MEDIA_TARGET_DIR:-./uploads}" +if [[ ! -f "${SOURCE}" ]]; then + echo "Backup source not found: ${SOURCE}" >&2 + exit 1 +fi + +mkdir -p "${TARGET_DIR}" +rm -rf "${TARGET_DIR}"/* +tar -xzf "${SOURCE}" -C "${TARGET_DIR}" +echo "Media restore completed into ${TARGET_DIR}" diff --git a/deploy/scripts/backup/restore-postgres.sh b/deploy/scripts/backup/restore-postgres.sh new file mode 100644 index 0000000..7e8c5df --- /dev/null +++ b/deploy/scripts/backup/restore-postgres.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if [[ -z "${DATABASE_URL:-}" ]]; then + echo "DATABASE_URL is required" >&2 + exit 1 +fi + +BACKUP_FILE="$1" +if [[ ! -f "${BACKUP_FILE}" ]]; then + echo "Backup file not found: ${BACKUP_FILE}" >&2 + exit 1 +fi + +pg_restore --clean --if-exists --no-owner --no-privileges --dbname="${DATABASE_URL}" "${BACKUP_FILE}" +echo "Postgres restore completed from ${BACKUP_FILE}" diff --git a/deploy/scripts/backup/sync-backups-offsite.sh b/deploy/scripts/backup/sync-backups-offsite.sh new file mode 100644 index 0000000..9461043 --- /dev/null +++ b/deploy/scripts/backup/sync-backups-offsite.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +BACKUP_ROOT="${BACKUP_ROOT:-./backups}" +OFFSITE_TARGET="${OFFSITE_TARGET:-}" +AWS_EXTRA_ARGS="${AWS_EXTRA_ARGS:-}" +RSYNC_EXTRA_ARGS="${RSYNC_EXTRA_ARGS:-}" + +if [[ -z "${OFFSITE_TARGET}" ]]; then + echo "OFFSITE_TARGET is required (rsync path or s3:// bucket)" >&2 + exit 1 +fi + +if [[ ! -d "${BACKUP_ROOT}" ]]; then + echo "Backup root not found: ${BACKUP_ROOT}" >&2 + exit 1 +fi + +if [[ "${OFFSITE_TARGET}" == s3://* ]]; then + aws s3 sync "${BACKUP_ROOT}" "${OFFSITE_TARGET}" ${AWS_EXTRA_ARGS} + echo "Backups synced to ${OFFSITE_TARGET}" + exit 0 +fi + +rsync -av --delete ${RSYNC_EXTRA_ARGS} "${BACKUP_ROOT}/" "${OFFSITE_TARGET}/" +echo "Backups synced to ${OFFSITE_TARGET}" diff --git a/deploy/scripts/backup/verify-restore.sh b/deploy/scripts/backup/verify-restore.sh new file mode 100644 index 0000000..4be434d --- /dev/null +++ b/deploy/scripts/backup/verify-restore.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:?DATABASE_URL is required}" +: "${POSTGRES_BACKUP:?POSTGRES_BACKUP is required}" +: "${MARKDOWN_BACKUP:?MARKDOWN_BACKUP is required}" +: "${MEDIA_BACKUP:?MEDIA_BACKUP is required}" + +POSTGRES_RESTORE_CMD="${POSTGRES_RESTORE_CMD:-./deploy/scripts/backup/restore-postgres.sh}" +MARKDOWN_RESTORE_CMD="${MARKDOWN_RESTORE_CMD:-./deploy/scripts/backup/restore-markdown.sh}" +MEDIA_RESTORE_CMD="${MEDIA_RESTORE_CMD:-./deploy/scripts/backup/restore-media.sh}" + +"${POSTGRES_RESTORE_CMD}" "${POSTGRES_BACKUP}" +"${MARKDOWN_RESTORE_CMD}" "${MARKDOWN_BACKUP}" +"${MEDIA_RESTORE_CMD}" "${MEDIA_BACKUP}" + +echo "Restore rehearsal completed. Please verify homepage, article detail, media assets, admin login, revisions, audit logs, and subscriptions manually." diff --git a/deploy/scripts/render_compose_env.py b/deploy/scripts/render_compose_env.py new file mode 100644 index 0000000..d0e3e4c --- /dev/null +++ b/deploy/scripts/render_compose_env.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +try: + import yaml +except ImportError as exc: # pragma: no cover + raise SystemExit( + "Missing dependency: PyYAML. Install it with `python -m pip install pyyaml`." + ) from exc + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render docker compose .env from deploy config.yaml" + ) + parser.add_argument( + "--input", + default="deploy/docker/config.yaml", + help="Path to config.yaml (default: deploy/docker/config.yaml)", + ) + parser.add_argument( + "--output", + default="deploy/docker/.env", + help="Output dotenv file path (default: deploy/docker/.env)", + ) + parser.add_argument( + "--section", + default="compose_env", + help="Top-level mapping section to export (default: compose_env)", + ) + parser.add_argument( + "--stdout", + action="store_true", + help="Print rendered dotenv to stdout instead of writing file", + ) + return parser.parse_args() + + +def load_config(path: Path) -> dict[str, Any]: + if not path.exists(): + raise SystemExit(f"Config file not found: {path}") + + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SystemExit("config.yaml root must be a mapping/object") + return data + + +def encode_env_value(value: Any) -> str: + if value is None: + return '""' + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if not isinstance(value, str): + raise SystemExit(f"compose_env only supports scalar values, got: {type(value).__name__}") + + if value == "": + return '""' + + needs_quotes = any(ch in value for ch in [' ', '#', '"', "'", '\t', '\n', '\r']) or value.startswith('$') + if not needs_quotes: + return value + + escaped = ( + value.replace('\\', '\\\\') + .replace('"', '\\"') + .replace('\n', '\\n') + .replace('\r', '\\r') + .replace('\t', '\\t') + ) + return f'"{escaped}"' + + +def render_env(section_name: str, values: dict[str, Any], source_path: Path) -> str: + lines = [ + f"# Generated from {source_path.as_posix()}::{section_name}", + "# Do not edit this file directly; edit config.yaml and re-render.", + "", + ] + + for key, value in values.items(): + if not isinstance(key, str) or not key: + raise SystemExit(f"Invalid env key: {key!r}") + lines.append(f"{key}={encode_env_value(value)}") + + lines.append("") + return "\n".join(lines) + + +def main() -> int: + args = parse_args() + source_path = Path(args.input) + output_path = Path(args.output) + + data = load_config(source_path) + section = data.get(args.section) + if not isinstance(section, dict): + raise SystemExit( + f"Section `{args.section}` must exist in config.yaml and must be a mapping/object" + ) + + rendered = render_env(args.section, section, source_path) + + if args.stdout: + sys.stdout.write(rendered) + return 0 + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered, encoding="utf-8", newline="\n") + print(f"Wrote {output_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/systemd/GO_LIVE_CHECKLIST.md b/deploy/systemd/GO_LIVE_CHECKLIST.md new file mode 100644 index 0000000..5859269 --- /dev/null +++ b/deploy/systemd/GO_LIVE_CHECKLIST.md @@ -0,0 +1,190 @@ +# 通知 / digest / systemd 最终上线清单 + +这份清单按“上线前 -> 首次启动 -> 验证 -> 定时任务”来执行。 + +## A. 上线前参数确认 + +### 1. backend / compose +确认 `deploy/docker/config.yaml` 至少已经填写: + +- `compose_env.DATABASE_URL` +- `compose_env.REDIS_URL` +- `compose_env.JWT_SECRET` +- `compose_env.APP_BASE_URL` +- `compose_env.PUBLIC_API_BASE_URL` +- `compose_env.ADMIN_API_BASE_URL` +- `compose_env.ADMIN_FRONTEND_BASE_URL` +- `compose_env.TERMI_ADMIN_TRUST_PROXY_AUTH=true` +- `compose_env.TERMI_ADMIN_LOCAL_LOGIN_ENABLED=false` +- `compose_env.TERMI_ADMIN_PROXY_SHARED_SECRET` + +### 2. SMTP +订阅 double opt-in 和邮件通知需要: + +- `compose_env.SMTP_ENABLE=true` +- `compose_env.SMTP_HOST` +- `compose_env.SMTP_PORT` +- `compose_env.SMTP_SECURE` +- `compose_env.SMTP_USER` +- `compose_env.SMTP_PASSWORD` +- `compose_env.SMTP_HELLO_NAME` + +### 3. Caddy / TinyAuth / Pocket ID +确认宿主机 Caddy 已经: + +- `blog.init.cool` -> `127.0.0.1:4321` +- `admin.blog.init.cool` -> `127.0.0.1:4322` +- `admin.blog.init.cool/api/*` -> `127.0.0.1:5150` +- `admin.blog.init.cool` 整体挂了 `import tinyauth` +- `/api/*` 转发时附带: + - `X-Termi-Proxy-Secret: {$TERMI_ADMIN_PROXY_SHARED_SECRET}` + +可直接参考: + +- `deploy/caddy/Caddyfile.tohka.production.example` + +## B. 首次启动 + +推荐命令: + +```bash +python deploy/scripts/render_compose_env.py \ + --input deploy/docker/config.yaml \ + --output deploy/docker/.env + +docker compose \ + -f deploy/docker/compose.package.yml \ + -f deploy/docker/compose.tohka.override.yml \ + --env-file deploy/docker/.env up -d +``` + +然后确认容器状态: + +```bash +docker compose \ + -f deploy/docker/compose.package.yml \ + -f deploy/docker/compose.tohka.override.yml \ + --env-file deploy/docker/.env ps +``` + +应该至少看到: + +- `backend` +- `backend-worker` +- `frontend` +- `admin` + +## C. 首次验证 + +### 1. 健康检查 + +```bash +curl -I http://127.0.0.1:5150/healthz +curl -I http://127.0.0.1:4321/healthz +curl -I http://127.0.0.1:4322/healthz +``` + +### 2. 后台 SSO + +打开: + +- `https://admin.blog.init.cool` + +确认: + +- 可以被 TinyAuth / Pocket ID 正常拦截与登录 +- 登录后后台可进入 +- 会话信息显示为代理登录(不是本地账号密码) + +### 3. 订阅链路 + +至少做一次: + +1. 前台提交邮箱订阅 +2. 收到确认邮件 +3. 点击确认链接 +4. 能打开偏好页 +5. 偏好页可暂停 / 恢复 / 退订 + +### 4. ntfy / webhook / digest + +后台里至少验证一次: + +- 手动发送测试通知 +- 手动发送周报 +- 手动发送月报 +- 查看 delivery 是否从 `queued` 变成 `sent` + +如果 delivery 一直卡在 `queued`: + +- 优先检查 `backend-worker` 是否在运行 +- 检查 `REDIS_URL` +- 检查 SMTP / ntfy / webhook 目标 + +## D. 安装 systemd timers + +```bash +sudo cp deploy/systemd/*.service /etc/systemd/system/ +sudo cp deploy/systemd/*.timer /etc/systemd/system/ +sudo systemctl daemon-reload +``` + +启用: + +```bash +sudo systemctl enable --now termi-retry-deliveries.timer +sudo systemctl enable --now termi-weekly-digest.timer +sudo systemctl enable --now termi-monthly-digest.timer +sudo systemctl enable --now termi-backup-all.timer +sudo systemctl enable --now termi-backup-prune.timer +sudo systemctl enable --now termi-backup-offsite-sync.timer +``` + +查看状态: + +```bash +systemctl list-timers --all | grep termi +``` + +## E. 当前默认调度时间 + +### 通知 / digest + +- `termi-retry-deliveries.timer` + - 每 5 分钟执行一次 +- `termi-weekly-digest.timer` + - 每周一 09:00 +- `termi-monthly-digest.timer` + - 每月 1 日 09:30 + +### 备份 + +- `termi-backup-all.timer` + - 每天 03:10 +- `termi-backup-prune.timer` + - 每天 04:15 +- `termi-backup-offsite-sync.timer` + - 每天 04:40 + +如果时区不是你们想要的,改 `.timer` 里的 `OnCalendar=` 后重新: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart termi-retry-deliveries.timer +sudo systemctl restart termi-weekly-digest.timer +sudo systemctl restart termi-monthly-digest.timer +sudo systemctl restart termi-backup-all.timer +sudo systemctl restart termi-backup-prune.timer +sudo systemctl restart termi-backup-offsite-sync.timer +``` + +## F. 上线后一周内建议额外确认 + +- [ ] 有真实订阅确认邮件成功送达 +- [ ] 有真实 ntfy / webhook 通知成功送达 +- [ ] 至少有一条 digest 成功发出 +- [ ] retry timer 能把失败投递重新入队 +- [ ] 备份目录持续产生新文件 +- [ ] prune 正常清理旧备份 +- [ ] offsite sync 确实有异地副本 +- [ ] 至少做过一次恢复演练 diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md new file mode 100644 index 0000000..9a05dec --- /dev/null +++ b/deploy/systemd/README.md @@ -0,0 +1,27 @@ +# systemd timer 模板 + +这些模板默认假设: + +- 仓库部署路径:`/opt/termi-astro` +- 使用 `docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env` +- backend / backend-worker 容器已长期运行 + +启用方式示例: + +```bash +sudo cp deploy/systemd/*.service /etc/systemd/system/ +sudo cp deploy/systemd/*.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now termi-backup-all.timer +sudo systemctl enable --now termi-backup-prune.timer +sudo systemctl enable --now termi-backup-offsite-sync.timer +sudo systemctl enable --now termi-retry-deliveries.timer +sudo systemctl enable --now termi-weekly-digest.timer +sudo systemctl enable --now termi-monthly-digest.timer +``` + +如果你们不使用 systemd,也可以直接参考同目录命令改成 cron。 + +最终上线前建议再对照: + +- `deploy/systemd/GO_LIVE_CHECKLIST.md` diff --git a/deploy/systemd/termi-backup-all.service b/deploy/systemd/termi-backup-all.service new file mode 100644 index 0000000..e5640f5 --- /dev/null +++ b/deploy/systemd/termi-backup-all.service @@ -0,0 +1,9 @@ +[Unit] +Description=Termi backup all data +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/termi-astro +ExecStart=/usr/bin/env bash ./deploy/scripts/backup/backup-all.sh diff --git a/deploy/systemd/termi-backup-all.timer b/deploy/systemd/termi-backup-all.timer new file mode 100644 index 0000000..b1c18f0 --- /dev/null +++ b/deploy/systemd/termi-backup-all.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run Termi full backup nightly + +[Timer] +OnCalendar=*-*-* 03:10:00 +Persistent=true +Unit=termi-backup-all.service + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/termi-backup-offsite-sync.service b/deploy/systemd/termi-backup-offsite-sync.service new file mode 100644 index 0000000..ae758c0 --- /dev/null +++ b/deploy/systemd/termi-backup-offsite-sync.service @@ -0,0 +1,10 @@ +[Unit] +Description=Termi sync backups offsite +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/termi-astro +Environment=OFFSITE_TARGET=/mnt/offsite/termi-astro-backups +ExecStart=/usr/bin/env bash ./deploy/scripts/backup/sync-backups-offsite.sh diff --git a/deploy/systemd/termi-backup-offsite-sync.timer b/deploy/systemd/termi-backup-offsite-sync.timer new file mode 100644 index 0000000..04eb826 --- /dev/null +++ b/deploy/systemd/termi-backup-offsite-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Sync Termi backups offsite every morning + +[Timer] +OnCalendar=*-*-* 04:40:00 +Persistent=true +Unit=termi-backup-offsite-sync.service + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/termi-backup-prune.service b/deploy/systemd/termi-backup-prune.service new file mode 100644 index 0000000..02ac298 --- /dev/null +++ b/deploy/systemd/termi-backup-prune.service @@ -0,0 +1,8 @@ +[Unit] +Description=Termi prune local backups +After=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/termi-astro +ExecStart=/usr/bin/env bash ./deploy/scripts/backup/prune-backups.sh diff --git a/deploy/systemd/termi-backup-prune.timer b/deploy/systemd/termi-backup-prune.timer new file mode 100644 index 0000000..c83ee3f --- /dev/null +++ b/deploy/systemd/termi-backup-prune.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Prune old Termi backups daily + +[Timer] +OnCalendar=*-*-* 04:15:00 +Persistent=true +Unit=termi-backup-prune.service + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/termi-monthly-digest.service b/deploy/systemd/termi-monthly-digest.service new file mode 100644 index 0000000..0397ab3 --- /dev/null +++ b/deploy/systemd/termi-monthly-digest.service @@ -0,0 +1,9 @@ +[Unit] +Description=Termi monthly digest dispatcher +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/termi-astro +ExecStart=/usr/bin/docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env exec -T backend termi_api-cli -e production task send_monthly_digest diff --git a/deploy/systemd/termi-monthly-digest.timer b/deploy/systemd/termi-monthly-digest.timer new file mode 100644 index 0000000..77d1397 --- /dev/null +++ b/deploy/systemd/termi-monthly-digest.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run monthly digest on day 1 + +[Timer] +OnCalendar=*-*-01 09:30:00 +Persistent=true +Unit=termi-monthly-digest.service + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/termi-retry-deliveries.service b/deploy/systemd/termi-retry-deliveries.service new file mode 100644 index 0000000..b00b164 --- /dev/null +++ b/deploy/systemd/termi-retry-deliveries.service @@ -0,0 +1,9 @@ +[Unit] +Description=Termi retry queued notification deliveries +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/termi-astro +ExecStart=/usr/bin/docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env exec -T backend termi_api-cli -e production task retry_deliveries limit:200 diff --git a/deploy/systemd/termi-retry-deliveries.timer b/deploy/systemd/termi-retry-deliveries.timer new file mode 100644 index 0000000..c4806c7 --- /dev/null +++ b/deploy/systemd/termi-retry-deliveries.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Retry notification deliveries every 5 minutes + +[Timer] +OnCalendar=*:0/5 +Persistent=true +Unit=termi-retry-deliveries.service + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/termi-weekly-digest.service b/deploy/systemd/termi-weekly-digest.service new file mode 100644 index 0000000..a056fde --- /dev/null +++ b/deploy/systemd/termi-weekly-digest.service @@ -0,0 +1,9 @@ +[Unit] +Description=Termi weekly digest dispatcher +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/termi-astro +ExecStart=/usr/bin/docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env exec -T backend termi_api-cli -e production task send_weekly_digest diff --git a/deploy/systemd/termi-weekly-digest.timer b/deploy/systemd/termi-weekly-digest.timer new file mode 100644 index 0000000..468cfe3 --- /dev/null +++ b/deploy/systemd/termi-weekly-digest.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run weekly digest every Monday morning + +[Timer] +OnCalendar=Mon *-*-* 09:00:00 +Persistent=true +Unit=termi-weekly-digest.service + +[Install] +WantedBy=timers.target diff --git a/dev.ps1 b/dev.ps1 index 66c7126..c3ba5aa 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -1,42 +1,231 @@ param( + [ValidateSet("frontend", "backend", "admin", "mcp")] + [string]$Only, + [switch]$Spawn, + [switch]$WithMcp, + [switch]$Install, + [string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development", + [string]$McpApiKey = "termi-mcp-local-dev-key", + [string]$McpBackendApiBase = "http://127.0.0.1:5150/api", + [int]$McpPort = 5151, [switch]$FrontendOnly, [switch]$BackendOnly, - [string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development" + [switch]$AdminOnly, + [switch]$McpOnly ) $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path -$frontendScript = Join-Path $repoRoot "start-frontend.ps1" -$backendScript = Join-Path $repoRoot "start-backend.ps1" +$devScriptPath = $MyInvocation.MyCommand.Path +$serviceOrder = @("frontend", "admin", "backend") -if ($FrontendOnly -and $BackendOnly) { - throw "Use either -FrontendOnly or -BackendOnly, not both." +function Resolve-TargetService { + if ($Only) { + return $Only + } + + $legacyTargets = @( + @{ Enabled = $FrontendOnly; Name = "frontend" } + @{ Enabled = $BackendOnly; Name = "backend" } + @{ Enabled = $AdminOnly; Name = "admin" } + @{ Enabled = $McpOnly; Name = "mcp" } + ) | Where-Object { $_.Enabled } + + if ($legacyTargets.Count -gt 1) { + throw "Use only one of -Only, -FrontendOnly, -BackendOnly, -AdminOnly, or -McpOnly." + } + + if ($legacyTargets.Count -eq 1) { + return $legacyTargets[0].Name + } + + return $null } -if ($FrontendOnly) { - & $frontendScript +function Invoke-RepoCommand { + param( + [string]$Name, + [string]$WorkingDirectory, + [scriptblock]$Run, + [switch]$UsesNode + ) + + if (-not (Test-Path $WorkingDirectory)) { + throw "$Name directory not found: $WorkingDirectory" + } + + Push-Location $WorkingDirectory + + try { + if ($UsesNode -and ($Install -or -not (Test-Path (Join-Path $WorkingDirectory "node_modules")))) { + Write-Host "[$Name] Installing dependencies..." -ForegroundColor Cyan + npm install + if ($LASTEXITCODE -ne 0) { + throw "npm install failed for $Name" + } + } + + & $Run + if ($LASTEXITCODE -ne 0) { + throw "$Name failed to start" + } + } + finally { + Pop-Location + } +} + +function Start-Frontend { + Invoke-RepoCommand ` + -Name "frontend" ` + -WorkingDirectory (Join-Path $repoRoot "frontend") ` + -UsesNode ` + -Run { + Write-Host "[frontend] Starting Astro dev server..." -ForegroundColor Green + npm run dev + } +} + +function Start-Admin { + Invoke-RepoCommand ` + -Name "admin" ` + -WorkingDirectory (Join-Path $repoRoot "admin") ` + -UsesNode ` + -Run { + Write-Host "[admin] Starting Vite admin workspace..." -ForegroundColor Green + npm run dev + } +} + +function Start-Backend { + Invoke-RepoCommand ` + -Name "backend" ` + -WorkingDirectory (Join-Path $repoRoot "backend") ` + -Run { + $env:DATABASE_URL = $DatabaseUrl + Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan + Write-Host "[backend] Starting Loco.rs server..." -ForegroundColor Green + cargo loco start 2>&1 + } +} + +function Start-Mcp { + Invoke-RepoCommand ` + -Name "mcp" ` + -WorkingDirectory (Join-Path $repoRoot "mcp-server") ` + -UsesNode ` + -Run { + $env:TERMI_MCP_API_KEY = $McpApiKey + $env:TERMI_BACKEND_API_BASE = $McpBackendApiBase + $env:TERMI_MCP_PORT = "$McpPort" + + Write-Host "[mcp] Backend API base set to $McpBackendApiBase" -ForegroundColor Cyan + Write-Host "[mcp] Starting MCP server on port $McpPort..." -ForegroundColor Green + npm run start + } +} + +function Invoke-Service { + param([string]$Name) + + switch ($Name) { + "frontend" { Start-Frontend; return } + "admin" { Start-Admin; return } + "backend" { Start-Backend; return } + "mcp" { Start-Mcp; return } + default { throw "Unsupported service: $Name" } + } +} + +function Get-ServiceLaunchArguments { + param([string]$Name) + + $arguments = @( + "powershell", + "-NoExit", + "-ExecutionPolicy", "Bypass", + "-File", $devScriptPath, + "-Only", $Name + ) + + if ($Install -and $Name -ne "backend") { + $arguments += "-Install" + } + + if ($Name -eq "backend") { + $arguments += @("-DatabaseUrl", $DatabaseUrl) + } + + if ($Name -eq "mcp") { + $arguments += @( + "-McpApiKey", $McpApiKey, + "-McpBackendApiBase", $McpBackendApiBase, + "-McpPort", $McpPort + ) + } + + return $arguments +} + +function Start-ServiceWindow { + param([string]$Name) + + $arguments = Get-ServiceLaunchArguments -Name $Name + Start-Process powershell -ArgumentList $arguments[1..($arguments.Length - 1)] +} + +function Start-ServiceHost { + param([string[]]$Services) + + $wt = Get-Command wt.exe -ErrorAction SilentlyContinue + if (-not $wt) { + Write-Warning "[dev] Windows Terminal (wt.exe) not found. Falling back to separate PowerShell windows." + foreach ($service in $Services) { + Start-ServiceWindow $service + } + return + } + + $wtArguments = @("-w", "0") + $isFirst = $true + + foreach ($service in $Services) { + if (-not $isFirst) { + $wtArguments += ";" + } + + $wtArguments += @( + "new-tab", + "--title", "termi:$service" + ) + $wtArguments += Get-ServiceLaunchArguments -Name $service + $isFirst = $false + } + + Start-Process -FilePath $wt.Source -ArgumentList $wtArguments +} + +$targetService = Resolve-TargetService + +if ($targetService -and -not $Spawn) { + Invoke-Service $targetService exit $LASTEXITCODE } -if ($BackendOnly) { - & $backendScript -DatabaseUrl $DatabaseUrl - exit $LASTEXITCODE +$servicesToStart = [System.Collections.Generic.List[string]]::new() +if ($targetService) { + [void]$servicesToStart.Add($targetService) +} +else { + $serviceOrder | ForEach-Object { [void]$servicesToStart.Add($_) } + if ($WithMcp) { + [void]$servicesToStart.Add("mcp") + } } -Write-Host "[monorepo] Starting frontend and backend in separate PowerShell windows..." -ForegroundColor Cyan +$serviceLabel = ($servicesToStart -join ", ") +Write-Host "[dev] Starting $serviceLabel in one Windows Terminal window..." -ForegroundColor Cyan +Start-ServiceHost -Services $servicesToStart -Start-Process powershell -ArgumentList @( - "-NoExit", - "-ExecutionPolicy", "Bypass", - "-File", $frontendScript -) - -Start-Process powershell -ArgumentList @( - "-NoExit", - "-ExecutionPolicy", "Bypass", - "-File", $backendScript, - "-DatabaseUrl", $DatabaseUrl -) - -Write-Host "[monorepo] Frontend window and backend window started." -ForegroundColor Green +Write-Host "[dev] Ready. Use .\\stop-services.ps1 to stop everything." -ForegroundColor Green diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..d74bb8a --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.gitignore +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a5ac42b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-alpine AS builder +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . + +ARG PUBLIC_API_BASE_URL=http://localhost:5150/api +ENV PUBLIC_API_BASE_URL=${PUBLIC_API_BASE_URL} + +RUN pnpm build + +FROM node:22-alpine AS runner +WORKDIR /app + +COPY --from=builder /app/dist ./dist + +ENV HOST=0.0.0.0 +ENV PORT=4321 +EXPOSE 4321 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=5 CMD node -e "fetch('http://127.0.0.1:4321/healthz').then((res)=>{if(!res.ok)process.exit(1)}).catch(()=>process.exit(1))" + +CMD ["node", "./dist/server/entry.mjs"] diff --git a/frontend/README.md b/frontend/README.md index 87b813a..14169b9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,43 +1,60 @@ -# Astro Starter Kit: Minimal +# frontend -```sh -npm create astro@latest -- --template minimal +Astro 前台站点,当前运行模式为: + +- `output: 'server'` +- `@astrojs/node` standalone +- 少量 Svelte 组件用于客户端激活 + +## 常用命令 + +```powershell +pnpm install +pnpm dev +pnpm build ``` -> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! +默认本地开发端口: -## 🚀 Project Structure +- frontend: `4321` -Inside of your Astro project, you'll see the following folders and files: +## API 地址约定 -```text -/ -├── public/ -├── src/ -│ └── pages/ -│ └── index.astro -└── package.json +前台现在区分两类 API 地址: + +- `INTERNAL_API_BASE_URL` + - 给 Astro SSR / Node 服务端渲染访问 backend 用 + - docker compose 默认推荐:`http://backend:5150/api` +- `PUBLIC_API_BASE_URL` + - 给浏览器里的评论、AI 问答、搜索等请求用 + - 如果不设置,生产环境会回退到“当前访问主机 + `:5150/api`” + +如果你走正式域名 / HTTPS / 反向代理,建议显式设置: + +```env +PUBLIC_API_BASE_URL=https://api.blog.init.cool ``` -Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. +## 图片处理链 -There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. +前台现在额外带了一个 SSR 图片优化端点: -Any static assets, like images, can be placed in the `public/` directory. +- `/_img` -## 🧞 Commands +它会对**同域图片**做: -All commands are run from the root of the project, from a terminal: +- 响应式尺寸裁切/缩放 +- `AVIF / WebP / JPEG|PNG` 输出 +- 前台卡片 / 文章页封面的 `srcset` 生成 -| Command | Action | -| :------------------------ | :----------------------------------------------- | -| `npm install` | Installs dependencies | -| `npm run dev` | Starts local dev server at `localhost:4321` | -| `npm run build` | Build your production site to `./dist/` | -| `npm run preview` | Preview your build locally, before deploying | -| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | -| `npm run astro -- --help` | Get help using the Astro CLI | +如果你的封面图来自额外 CDN / R2 公网域名,需要给 frontend 运行时增加: -## 👀 Want to learn more? +```env +PUBLIC_IMAGE_ALLOWED_HOSTS=cdn.example.com,pub-xxxx.r2.dev +``` -Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). +admin 侧上传封面时也会额外做: + +- 上传前压缩 +- 16:9 封面规范化 +- 优先转为 `AVIF / WebP` diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs index 441ef21..807ccfc 100644 --- a/frontend/astro.config.mjs +++ b/frontend/astro.config.mjs @@ -1,14 +1,32 @@ // @ts-check import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; import svelte from '@astrojs/svelte'; import tailwind from '@astrojs/tailwind'; +const nodeProcess = /** @type {any} */ (globalThis).process; +const disableHmrForLighthouse = nodeProcess?.env?.LIGHTHOUSE_NO_HMR === '1'; + // https://astro.build/config export default defineConfig({ + output: 'server', + adapter: node({ + mode: 'standalone', + }), integrations: [ svelte(), tailwind({ applyBaseStyles: false }) - ] + ], + devToolbar: { + enabled: false, + }, + vite: disableHmrForLighthouse + ? { + server: { + hmr: false, + }, + } + : undefined, }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 287f5ae..3605507 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "termi-astro", "version": "0.0.1", "dependencies": { + "@astrojs/node": "^10.0.4", "@astrojs/svelte": "^8.0.3", "@astrojs/tailwind": "^6.0.2", "@tailwindcss/typography": "^0.5.19", @@ -179,6 +180,20 @@ "vfile": "^6.0.3" } }, + "node_modules/@astrojs/node": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-10.0.4.tgz", + "integrity": "sha512-7pVgiVSscQHRC2WqjlXcnbbcKMYp2GXrYpmuvdGg5zgA8J1lFm2vmwVhHZFuZK3Ik5PzoxiDROaEgoDGLbfhLw==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.8.0", + "send": "^1.2.1", + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "^6.0.0" + } + }, "node_modules/@astrojs/prism": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz", @@ -2728,6 +2743,15 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2869,6 +2893,12 @@ "node": ">=4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.325", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", @@ -2899,6 +2929,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2967,6 +3006,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -3001,6 +3046,15 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -3146,6 +3200,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3425,6 +3488,32 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -4526,6 +4615,31 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -4683,6 +4797,18 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", @@ -5057,6 +5183,15 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5457,6 +5592,44 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/server-destroy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", + "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -5558,6 +5731,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5901,6 +6083,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9177d90..ae7c659 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "astro": "astro" }, "dependencies": { + "@astrojs/node": "^10.0.4", "@astrojs/svelte": "^8.0.3", "@astrojs/tailwind": "^6.0.2", "@tailwindcss/typography": "^0.5.19", @@ -19,6 +20,7 @@ "autoprefixer": "^10.4.27", "lucide-astro": "^0.556.0", "postcss": "^8.5.8", + "sharp": "^0.34.5", "svelte": "^5.55.0", "tailwindcss": "^3.4.19" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..1b9ec81 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,4543 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@astrojs/node': + specifier: ^10.0.4 + version: 10.0.4(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)) + '@astrojs/svelte': + specifier: ^8.0.3 + version: 8.0.3(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3) + '@astrojs/tailwind': + specifier: ^6.0.2 + version: 6.0.2(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19) + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@3.4.19) + astro: + specifier: ^6.0.8 + version: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3) + autoprefixer: + specifier: ^10.4.27 + version: 10.4.27(postcss@8.5.8) + lucide-astro: + specifier: ^0.556.0 + version: 0.556.0(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)) + postcss: + specifier: ^8.5.8 + version: 8.5.8 + sharp: + specifier: ^0.34.5 + version: 0.34.5 + svelte: + specifier: ^5.55.0 + version: 5.55.0 + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19 + devDependencies: + '@astrojs/check': + specifier: ^0.9.8 + version: 0.9.8(prettier@3.8.1)(typescript@6.0.2) + typescript: + specifier: ^6.0.2 + version: 6.0.2 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@astrojs/check@0.9.8': + resolution: {integrity: sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + + '@astrojs/compiler@2.13.1': + resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + + '@astrojs/compiler@3.0.1': + resolution: {integrity: sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==} + + '@astrojs/internal-helpers@0.8.0': + resolution: {integrity: sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w==} + + '@astrojs/language-server@2.16.6': + resolution: {integrity: sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-remark@7.0.1': + resolution: {integrity: sha512-zAfLJmn07u9SlDNNHTpjv0RT4F8D4k54NR7ReRas8CO4OeGoqSvOuKwqCFg2/cqN3wHwdWlK/7Yv/lMXlhVIaw==} + + '@astrojs/node@10.0.4': + resolution: {integrity: sha512-7pVgiVSscQHRC2WqjlXcnbbcKMYp2GXrYpmuvdGg5zgA8J1lFm2vmwVhHZFuZK3Ik5PzoxiDROaEgoDGLbfhLw==} + peerDependencies: + astro: ^6.0.0 + + '@astrojs/prism@4.0.1': + resolution: {integrity: sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==} + engines: {node: '>=22.12.0'} + + '@astrojs/svelte@8.0.3': + resolution: {integrity: sha512-R9vUtQGV+j4Zs3cPm2zRHCyYxQR4DRDEl7rgwIu5i4UpAlVBUYIu34onpgNZEpvws1rxvLhrA/N10qLrFNTYyw==} + engines: {node: '>=22.12.0'} + peerDependencies: + astro: ^6.0.0 + svelte: ^5.43.6 + typescript: ^5.3.3 + + '@astrojs/tailwind@6.0.2': + resolution: {integrity: sha512-j3mhLNeugZq6A8dMNXVarUa8K6X9AW+QHU9u3lKNrPLMHhOQ0S7VeWhHwEeJFpEK1BTKEUY1U78VQv2gN6hNGg==} + peerDependencies: + astro: ^3.0.0 || ^4.0.0 || ^5.0.0 + tailwindcss: ^3.0.24 + + '@astrojs/telemetry@3.3.0': + resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.3': + resolution: {integrity: sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@capsizecss/unpack@4.0.0': + resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} + engines: {node: '>=18'} + + '@clack/core@1.1.0': + resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} + + '@clack/prompts@1.1.0': + resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.0.2': + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} + + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@sveltejs/vite-plugin-svelte@6.2.4': + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/types@8.57.2': + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + astro@6.0.8: + resolution: {integrity: sha512-DCPeb8GKOoFWh+8whB7Qi/kKWD/6NcQ9nd1QVNzJFxgHkea3WYrNroQRq4whmBdjhkYPTLS/1gmUAl2iA2Es2g==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.10.10: + resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cookie-es@1.2.2: + resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dedent-js@1.0.1: + resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.325: + resolution: {integrity: sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + h3@1.15.10: + resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + + lucide-astro@0.556.0: + resolution: {integrity: sha512-ugMjPb45AMfkLCaduNSbyy5NQEKvB1TxVVMmUS4S6L807PMESnX0Qp+DIKHjbyjJmPXOyLRbrzvR3YikTK7brg==} + deprecated: 'Deprecated: Use `@lucide/astro`' + peerDependencies: + astro: '>=2.7.1' + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.5: + resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + + p-queue@9.1.0: + resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + server-destroy@1.0.1: + resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shiki@4.0.2: + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + engines: {node: '>=20'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte2tsx@0.7.52: + resolution: {integrity: sha512-svdT1FTrCLpvlU62evO5YdJt/kQ7nxgQxII/9BpQUvKr+GJRVdAXNVw8UWOt0fhoe5uWKyU0WsUTMRVAtRbMQg==} + peerDependencies: + svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 + typescript: ^4.9.4 || ^5.0.0 + + svelte@5.55.0: + resolution: {integrity: sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==} + engines: {node: '>=18'} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@0.1.12: + resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.4: + resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + + volar-service-css@0.0.70: + resolution: {integrity: sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.70: + resolution: {integrity: sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.70: + resolution: {integrity: sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.70: + resolution: {integrity: sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.70: + resolution: {integrity: sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.70: + resolution: {integrity: sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.70: + resolution: {integrity: sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml-language-server@1.20.0: + resolution: {integrity: sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==} + hasBin: true + + yaml@2.7.1: + resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} + engines: {node: '>= 14'} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@astrojs/check@0.9.8(prettier@3.8.1)(typescript@6.0.2)': + dependencies: + '@astrojs/language-server': 2.16.6(prettier@3.8.1)(typescript@6.0.2) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 6.0.2 + yargs: 17.7.2 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/compiler@3.0.1': {} + + '@astrojs/internal-helpers@0.8.0': + dependencies: + picomatch: 4.0.4 + + '@astrojs/language-server@2.16.6(prettier@3.8.1)(typescript@6.0.2)': + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/yaml2ts': 0.2.3 + '@jridgewell/sourcemap-codec': 1.5.5 + '@volar/kit': 2.4.28(typescript@6.0.2) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.15 + volar-service-css: 0.0.70(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.70(@volar/language-service@2.4.28) + volar-service-html: 0.0.70(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.70(@volar/language-service@2.4.28)(prettier@3.8.1) + volar-service-typescript: 0.0.70(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.70(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.70(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.1.0 + optionalDependencies: + prettier: 3.8.1 + transitivePeerDependencies: + - typescript + + '@astrojs/markdown-remark@7.0.1': + dependencies: + '@astrojs/internal-helpers': 0.8.0 + '@astrojs/prism': 4.0.1 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + js-yaml: 4.1.1 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + shiki: 4.0.2 + smol-toml: 1.6.1 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/node@10.0.4(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))': + dependencies: + '@astrojs/internal-helpers': 0.8.0 + astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3) + send: 1.2.1 + server-destroy: 1.0.1 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@4.0.1': + dependencies: + prismjs: 1.30.0 + + '@astrojs/svelte@8.0.3(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)) + astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3) + svelte: 5.55.0 + svelte2tsx: 0.7.52(svelte@5.55.0)(typescript@6.0.2) + typescript: 6.0.2 + vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + '@astrojs/tailwind@6.0.2(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)': + dependencies: + astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3) + autoprefixer: 10.4.27(postcss@8.5.8) + postcss: 8.5.8 + postcss-load-config: 4.0.2(postcss@8.5.8) + tailwindcss: 3.4.19 + transitivePeerDependencies: + - ts-node + + '@astrojs/telemetry@3.3.0': + dependencies: + ci-info: 4.4.0 + debug: 4.4.3 + dlv: 1.1.3 + dset: 3.1.4 + is-docker: 3.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@astrojs/yaml2ts@0.2.3': + dependencies: + yaml: 2.8.3 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@capsizecss/unpack@4.0.0': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.1.0': + dependencies: + sisteransi: 1.0.5 + + '@clack/prompts@1.1.0': + dependencies: + '@clack/core': 1.1.0 + sisteransi: 1.0.5 + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oslojs/encoding@1.1.0': {} + + '@rollup/pluginutils@5.3.0(rollup@4.60.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.0 + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@shikijs/core@4.0.2': + dependencies: + '@shikijs/primitive': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.5 + + '@shikijs/engine-oniguruma@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/primitive@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/types@4.0.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)) + obug: 2.1.1 + svelte: 5.55.0 + vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3) + + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)) + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.55.0 + vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3) + vitefu: 1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)) + + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.19 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/trusted-types@2.0.7': {} + + '@types/unist@3.0.3': {} + + '@typescript-eslint/types@8.57.2': {} + + '@ungap/structured-clone@1.3.0': {} + + '@volar/kit@2.4.28(typescript@6.0.2)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 6.0.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + + acorn@8.16.0: {} + + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.1: {} + + aria-query@5.3.2: {} + + array-iterate@2.0.1: {} + + astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3): + dependencies: + '@astrojs/compiler': 3.0.1 + '@astrojs/internal-helpers': 0.8.0 + '@astrojs/markdown-remark': 7.0.1 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@clack/prompts': 1.1.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.0) + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 1.1.1 + devalue: 5.6.4 + diff: 8.0.4 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 2.0.0 + esbuild: 0.27.4 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + obug: 2.1.1 + p-limit: 7.3.0 + p-queue: 9.1.0 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 4.0.2 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyclip: 0.1.12 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@6.0.2) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.4 + vfile: 6.0.3 + vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3) + vitefu: 1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.3.6 + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + + autoprefixer@10.4.27(postcss@8.5.8): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001781 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + baseline-browser-mapping@2.10.10: {} + + binary-extensions@2.3.0: {} + + boolbase@1.0.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.10 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.325 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001781: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + commander@4.1.1: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.2: {} + + cookie@1.1.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dedent-js@1.0.1: {} + + deepmerge@4.3.1: {} + + defu@6.1.4: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@5.6.4: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + didyoumean@1.2.2: {} + + diff@8.0.4: {} + + dlv@1.1.3: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.325: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-module-lexer@2.0.0: {} + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@5.0.0: {} + + esm-env@1.2.2: {} + + esrap@2.2.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.57.2 + + estree-walker@2.0.2: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fraction.js@5.3.4: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + h3@1.15.10: + dependencies: + cookie-es: 1.2.2 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.3 + uncrypto: 0.1.3 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + inherits@2.0.4: {} + + iron-webcrypto@1.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + jiti@1.21.7: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-character@3.0.0: {} + + longest-streak@3.1.0: {} + + lru-cache@11.2.7: {} + + lucide-astro@0.556.0(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)): + dependencies: + astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3) + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + markdown-table@3.0.4: {} + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.4: {} + + node-releases@2.0.36: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + obug@2.1.1: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.5: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.1.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-manager-detector@1.6.0: {} + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + path-parse@1.0.7: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.8): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.8 + + postcss-load-config@4.0.2(postcss@8.5.8): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.3 + optionalDependencies: + postcss: 8.5.8 + + postcss-nested@6.2.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.8.1: {} + + prismjs@1.30.0: {} + + property-information@7.1.0: {} + + queue-microtask@1.2.3: {} + + radix3@1.1.2: {} + + range-parser@1.2.1: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + reusify@1.1.0: {} + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sax@1.6.0: {} + + scule@1.3.0: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + server-destroy@1.0.1: {} + + setprototypeof@1.2.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + shiki@4.0.2: + dependencies: + '@shikijs/core': 4.0.2 + '@shikijs/engine-javascript': 4.0.2 + '@shikijs/engine-oniguruma': 4.0.2 + '@shikijs/langs': 4.0.2 + '@shikijs/themes': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + sisteransi@1.0.5: {} + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte2tsx@0.7.52(svelte@5.55.0)(typescript@6.0.2): + dependencies: + dedent-js: 1.0.1 + scule: 1.3.0 + svelte: 5.55.0 + typescript: 6.0.2 + + svelte@5.55.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.4 + esm-env: 1.2.2 + esrap: 2.2.4 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-import: 15.1.0(postcss@8.5.8) + postcss-js: 4.1.0(postcss@8.5.8) + postcss-load-config: 4.0.2(postcss@8.5.8) + postcss-nested: 6.2.0(postcss@8.5.8) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - ts-node + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tiny-inflate@1.0.3: {} + + tinyclip@0.1.12: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-interface-checker@0.1.13: {} + + tsconfck@3.1.6(typescript@6.0.2): + optionalDependencies: + typescript: 6.0.2 + + tslib@2.8.1: + optional: true + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.7.4 + + typescript@6.0.2: {} + + ufo@1.6.3: {} + + ultrahtml@1.6.0: {} + + uncrypto@0.1.3: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.4: + dependencies: + css-tree: 3.2.1 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.4: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.10 + lru-cache: 11.2.7 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.3 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.1(jiti@1.21.7)(yaml@2.8.3): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 1.21.7 + yaml: 2.8.3 + + vitefu@1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)): + optionalDependencies: + vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3) + + volar-service-css@0.0.70(@volar/language-service@2.4.28): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-emmet@0.0.70(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-html@0.0.70(@volar/language-service@2.4.28): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-prettier@0.0.70(@volar/language-service@2.4.28)(prettier@3.8.1): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + prettier: 3.8.1 + + volar-service-typescript-twoslash-queries@0.0.70(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-typescript@0.0.70(@volar/language-service@2.4.28): + dependencies: + path-browserify: 1.0.1 + semver: 7.7.4 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-yaml@0.0.70(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + yaml-language-server: 1.20.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.1.0: {} + + web-namespaces@2.0.1: {} + + which-pm-runs@1.1.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yaml-language-server@1.20.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + prettier: 3.8.1 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + yaml: 2.7.1 + + yaml@2.7.1: {} + + yaml@2.8.3: {} + + yargs-parser@21.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@1.2.2: {} + + zimmerframe@1.1.4: {} + + zod@4.3.6: {} + + zwitch@2.0.4: {} diff --git a/frontend/public/review-covers/black-myth-wukong.svg b/frontend/public/review-covers/black-myth-wukong.svg new file mode 100644 index 0000000..6a2eb52 --- /dev/null +++ b/frontend/public/review-covers/black-myth-wukong.svg @@ -0,0 +1,24 @@ + + + + + + + + + + 黑神话:悟空 + BLACK MYTH / WUKONG / GAME + + + + + + + + + + + + + diff --git a/frontend/public/review-covers/hero-dreams-in-tired-life.svg b/frontend/public/review-covers/hero-dreams-in-tired-life.svg new file mode 100644 index 0000000..a7f7139 --- /dev/null +++ b/frontend/public/review-covers/hero-dreams-in-tired-life.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + LATE NIGHT LOOP / INDIE POP + 疲惫生活中的 + 英雄梦想 + MUSIC REVIEW / MIDNIGHT LISTENING + + + + + + + + diff --git a/frontend/public/review-covers/journey-to-the-west-editorial.svg b/frontend/public/review-covers/journey-to-the-west-editorial.svg new file mode 100644 index 0000000..7e3fa14 --- /dev/null +++ b/frontend/public/review-covers/journey-to-the-west-editorial.svg @@ -0,0 +1,19 @@ + + + + + + + + + RETRO SCI-FI / FIELD NOTES + 宇宙探索编辑部 + JOURNEY TO THE WEST EDITORIAL + + + + + + + + diff --git a/frontend/public/review-covers/placed-within.svg b/frontend/public/review-covers/placed-within.svg new file mode 100644 index 0000000..5c66389 --- /dev/null +++ b/frontend/public/review-covers/placed-within.svg @@ -0,0 +1,20 @@ + + + + + + + + + + MACRO / CHINA / NOTES + 置身事内 + ECONOMY / NONFICTION / BOOK + + + + + + + + diff --git a/frontend/public/review-covers/the-long-season.svg b/frontend/public/review-covers/the-long-season.svg new file mode 100644 index 0000000..ea9099d --- /dev/null +++ b/frontend/public/review-covers/the-long-season.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + 漫长的季节 + THE LONG SEASON + FILM LOG / 2024 / NO.01 + + + + + + + + diff --git a/frontend/public/review-covers/thirteen-invites.svg b/frontend/public/review-covers/thirteen-invites.svg new file mode 100644 index 0000000..bfd002c --- /dev/null +++ b/frontend/public/review-covers/thirteen-invites.svg @@ -0,0 +1,20 @@ + + + + + + + + + + INTERVIEW DOSSIER + 十三邀 + THIRTEEN INVITES / VOL.13 + + + + + + + + diff --git a/frontend/src/components/BackToTop.astro b/frontend/src/components/BackToTop.astro deleted file mode 100644 index 4b201b4..0000000 --- a/frontend/src/components/BackToTop.astro +++ /dev/null @@ -1,46 +0,0 @@ ---- -// Back to Top Button Component ---- - - - - diff --git a/frontend/src/components/CodeCopyButton.astro b/frontend/src/components/CodeCopyButton.astro index bffc3d6..d5b50e0 100644 --- a/frontend/src/components/CodeCopyButton.astro +++ b/frontend/src/components/CodeCopyButton.astro @@ -5,6 +5,8 @@ diff --git a/frontend/src/components/Footer.astro b/frontend/src/components/Footer.astro index 2724a56..6654a59 100644 --- a/frontend/src/components/Footer.astro +++ b/frontend/src/components/Footer.astro @@ -1,6 +1,6 @@ --- -import { terminalConfig } from '../lib/config/terminal'; import { DEFAULT_SITE_SETTINGS } from '../lib/api/client'; +import { getI18n } from '../lib/i18n'; import type { SiteSettings } from '../lib/types'; interface Props { @@ -8,8 +8,13 @@ interface Props { } const { siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props; +const { t } = getI18n(Astro); const social = siteSettings.social; const currentYear = new Date().getFullYear(); +const tools = [ + { icon: 'fa-sitemap', href: '/sitemap.xml', title: t('footer.sitemap') }, + { icon: 'fa-rss', href: '/rss.xml', title: t('footer.rss') }, +]; ---