Compare commits
33 Commits
c67b638931
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 93aa73ab5e | |||
| 35cd438018 | |||
| 03c56a20db | |||
| 5c5d21bf43 | |||
| 2141afd3eb | |||
| 944a852470 | |||
| 009631bd43 | |||
| fb149b4e00 | |||
| c7ed88b2d8 | |||
| a3569a52e7 | |||
| d77954ba94 | |||
| d2edebecda | |||
| 999a1314a8 | |||
| 602f00262b | |||
| 475bab8bf6 | |||
| beb14bf834 | |||
| fc88270737 | |||
| 63867b4677 | |||
| 663143a30a | |||
| f77cf67b76 | |||
| 1460b71915 | |||
| 37131e1123 | |||
| deb999ff4d | |||
| 2a2274716e | |||
| 92eef39c5a | |||
| 5bb693e8f6 | |||
| 8a21a46554 | |||
| 54e8f370c8 | |||
| 2c3ce10837 | |||
| 9c65575eb3 | |||
| dbf7c78405 | |||
| 829e4db1cb | |||
| 0243d5de9c |
2
.env
@@ -44,7 +44,7 @@ S3_ENDPOINT=
|
||||
S3_BUCKET=
|
||||
S3_FORCE_PATH_STYLE=false
|
||||
REDIS_URL=
|
||||
ENCRYPTION_SECRET=
|
||||
ENCRYPTION_SECRET=10ddcad9814eaa5fa5bef8ba85d58a8122f3ede148bad11bc340899b5014af45
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
FLAG_DISABLE_SIGNUPS=false
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
- `生成简历/backups/`: 每次线上 `resume` 被替换前导出的历史版本备份。
|
||||
- `生成简历/visual-assets/`: 图文并茂版简历使用的轻量展示图。
|
||||
- `reactive_resume_data`: Reactive Resume 本地上传文件持久化 Docker 命名卷。
|
||||
- `packages/`: 三套可读可改的安装包目录。
|
||||
- `dist/`: 三套安装包的 `.tar.gz` / `.zip` 发布归档和 `SHA256SUMS`。
|
||||
|
||||
## 安装包
|
||||
|
||||
- `reactive-resume-clean-install-20260520`: 纯净迁移模板,域名、端口、FRP、密钥均为待填写占位。
|
||||
- `reactive-resume-personal-direct-20260520`: 服务器直接运行版,预置 `https://me.huijutec.cn`、FRP `remotePort = 10003`,并内置当前简历与上传图片。
|
||||
- `reactive-resume-personal-qnap-nas-20260520`: 威联通 QNAP NAS 直接部署版,预置 `/share/Container/reactive_resume`、本地端口 `3003`、FRP `10003`,并内置当前简历与上传图片。
|
||||
|
||||
## 启动
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
reactive_resume_app:
|
||||
image: amruthpillai/reactive-resume:latest
|
||||
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
reactive_resume_permissions:
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
# QTS/Container Station 版默认启动 frpc。
|
||||
# 为避免 QTS 把 frpc 配置文件挂载路径改到应用临时目录,这里不挂载 frpc.yaml,
|
||||
# 而是在容器启动时自动写入 /tmp/frpc.toml。
|
||||
image: snowdreamtech/frpc:latest
|
||||
image: fatedier/frpc:v0.68.0
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/sh"]
|
||||
command:
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
reactive-resume:
|
||||
image: amruthpillai/reactive-resume:latest
|
||||
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
3
dist/SHA256SUMS
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
1a1571bbed2c59c0003daa3c4c41cda7464b03cb8c4706bd7bf507e8abfaa7ea reactive-resume-clean-install-20260520.zip
|
||||
34764a874e5477e439fa2860ef311e42264e90575c5a89e3f1651748708ef4df reactive-resume-personal-direct-20260520.zip
|
||||
933541058567f73b6a0d9521863a0d592da6d0a546fd1c38587aeaa0f11cf61d reactive-resume-personal-qnap-nas-20260520.zip
|
||||
3
dist/SHA256SUMS-20260520
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
1a1571bbed2c59c0003daa3c4c41cda7464b03cb8c4706bd7bf507e8abfaa7ea reactive-resume-clean-install-20260520.zip
|
||||
34764a874e5477e439fa2860ef311e42264e90575c5a89e3f1651748708ef4df reactive-resume-personal-direct-20260520.zip
|
||||
933541058567f73b6a0d9521863a0d592da6d0a546fd1c38587aeaa0f11cf61d reactive-resume-personal-qnap-nas-20260520.zip
|
||||
BIN
dist/reactive-resume-clean-install-20260519.tar.gz
vendored
Normal file
BIN
dist/reactive-resume-clean-install-20260519.zip
vendored
Normal file
BIN
dist/reactive-resume-clean-install-20260520.zip
vendored
Normal file
BIN
dist/reactive-resume-personal-direct-20260519.tar.gz
vendored
Normal file
BIN
dist/reactive-resume-personal-direct-20260519.zip
vendored
Normal file
BIN
dist/reactive-resume-personal-direct-20260520.zip
vendored
Normal file
BIN
dist/reactive-resume-personal-qnap-nas-20260519.tar.gz
vendored
Normal file
BIN
dist/reactive-resume-personal-qnap-nas-20260519.zip
vendored
Normal file
BIN
dist/reactive-resume-personal-qnap-nas-20260520.zip
vendored
Normal file
59
packages/reactive-resume-clean-install/.env.example
Normal file
@@ -0,0 +1,59 @@
|
||||
# ===== Reactive Resume clean install environment =====
|
||||
# Copy this file to .env, then replace every CHANGE_ME / YOUR_* value.
|
||||
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# Public URL behind your reverse proxy.
|
||||
APP_URL=https://YOUR_DOMAIN.example.com
|
||||
|
||||
# Local debug binding on this Docker host.
|
||||
# Use 127.0.0.1 if only frpc / local reverse proxy should reach the app.
|
||||
LOCAL_BIND_IP=127.0.0.1
|
||||
LOCAL_APP_PORT=CHANGE_ME_LOCAL_PORT
|
||||
|
||||
# PostgreSQL.
|
||||
POSTGRES_DB=reactive_resume
|
||||
POSTGRES_USER=reactive_resume
|
||||
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
|
||||
DATABASE_URL=postgresql://reactive_resume:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/reactive_resume
|
||||
|
||||
# Generate with: openssl rand -hex 32
|
||||
AUTH_SECRET=CHANGE_ME_64_HEX_AUTH_SECRET
|
||||
ENCRYPTION_SECRET=CHANGE_ME_64_HEX_ENCRYPTION_SECRET
|
||||
|
||||
# Optional email/auth/storage/AI settings can be enabled later.
|
||||
BETTER_AUTH_API_KEY=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
OAUTH_PROVIDER_NAME=
|
||||
OAUTH_CLIENT_ID=
|
||||
OAUTH_CLIENT_SECRET=
|
||||
OAUTH_DISCOVERY_URL=
|
||||
OAUTH_AUTHORIZATION_URL=
|
||||
OAUTH_TOKEN_URL=
|
||||
OAUTH_USER_INFO_URL=
|
||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=
|
||||
OAUTH_SCOPES=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=Reactive Resume <noreply@YOUR_DOMAIN.example.com>
|
||||
SMTP_SECURE=false
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_REGION=us-east-1
|
||||
S3_ENDPOINT=
|
||||
S3_BUCKET=
|
||||
S3_FORCE_PATH_STYLE=false
|
||||
REDIS_URL=
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
FLAG_DISABLE_SIGNUPS=false
|
||||
FLAG_DISABLE_EMAIL_AUTH=false
|
||||
FLAG_DISABLE_IMAGE_PROCESSING=false
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL=false
|
||||
38
packages/reactive-resume-clean-install/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Reactive Resume 纯净迁移安装包
|
||||
|
||||
这是一套不绑定具体域名、端口、FRP 服务器或密码的纯净安装包,适合迁移到新服务器或新 NAS 前作为模板使用。
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `compose.yml`:通用 Linux / Docker Compose 部署版
|
||||
- `compose-Nas.yml`:威联通 QNAP QTS / Container Station 部署模板
|
||||
- `frpc.yaml`:通用服务器版 frpc 配置模板
|
||||
- `.env.example`:通用服务器版环境变量模板
|
||||
|
||||
## 通用服务器部署
|
||||
|
||||
1. 复制环境变量文件:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. 编辑 `.env` 和 `frpc.yaml`,把所有 `CHANGE_ME` / `YOUR_*` 改成真实值。
|
||||
|
||||
3. 启动:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yml up -d
|
||||
```
|
||||
|
||||
## QNAP NAS 部署
|
||||
|
||||
1. 编辑 `compose-Nas.yml` 顶部注释中的路径、域名、端口、FRP 服务器等待填写项。
|
||||
2. 在 QTS Container Station 中导入 `compose-Nas.yml`。
|
||||
3. 启动后,确认 app 和 frpc 都为 healthy / running。
|
||||
|
||||
## 注意
|
||||
|
||||
- `AUTH_SECRET` 和 `ENCRYPTION_SECRET` 必须使用长期固定值,迁移后不要随意更换。
|
||||
- `APP_URL` 必须与反向代理对外访问域名一致,例如 `https://resume.example.com`。
|
||||
- FRP 的 `remotePort` 需要与公网服务器 Nginx Proxy Manager / 反向代理转发端口一致。
|
||||
97
packages/reactive-resume-clean-install/compose-Nas.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
# Reactive Resume / QNAP QTS 纯净模板。
|
||||
# 使用前请填写:
|
||||
# - NAS 数据目录,例如 /share/Container/Reactive_Resume
|
||||
# - APP_URL,例如 https://resume.example.com
|
||||
# - 本地访问端口,例如 3004
|
||||
# - PostgreSQL 密码、AUTH_SECRET、ENCRYPTION_SECRET
|
||||
# - FRP serverAddr/serverPort/token/proxy name/remotePort
|
||||
|
||||
name: reactive-resume-nas
|
||||
|
||||
services:
|
||||
reactive_resume_permissions:
|
||||
image: alpine:3.20
|
||||
restart: "no"
|
||||
command: ["sh", "-c", "mkdir -p /app/data && chown -R 1000:1000 /app/data"]
|
||||
volumes:
|
||||
# 待填写:改成你的 NAS 数据目录。
|
||||
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/uploads:/app/data
|
||||
|
||||
reactive_resume_db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: reactive_resume
|
||||
POSTGRES_USER: reactive_resume
|
||||
POSTGRES_PASSWORD: CHANGE_ME_POSTGRES_PASSWORD
|
||||
volumes:
|
||||
# 待填写:改成你的 NAS 数据目录。
|
||||
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U reactive_resume -d reactive_resume"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
reactive_resume_app:
|
||||
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
reactive_resume_permissions:
|
||||
condition: service_completed_successfully
|
||||
reactive_resume_db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# 待填写:NAS 本地访问端口。
|
||||
- "CHANGE_ME_LOCAL_APP_PORT:3000"
|
||||
volumes:
|
||||
# 待填写:改成你的 NAS 数据目录。
|
||||
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/uploads:/app/data
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
APP_URL: https://YOUR_DOMAIN.example.com
|
||||
DATABASE_URL: postgresql://reactive_resume:CHANGE_ME_POSTGRES_PASSWORD@reactive_resume_db:5432/reactive_resume
|
||||
AUTH_SECRET: CHANGE_ME_64_HEX_AUTH_SECRET
|
||||
ENCRYPTION_SECRET: CHANGE_ME_64_HEX_ENCRYPTION_SECRET
|
||||
SMTP_FROM: Reactive Resume <noreply@YOUR_DOMAIN.example.com>
|
||||
SMTP_PORT: "587"
|
||||
SMTP_SECURE: "false"
|
||||
FLAG_DISABLE_SIGNUPS: "false"
|
||||
FLAG_DISABLE_EMAIL_AUTH: "false"
|
||||
FLAG_DISABLE_IMAGE_PROCESSING: "false"
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: "false"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
reactive_resume_frpc:
|
||||
image: fatedier/frpc:v0.68.0
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/sh"]
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
cat > /tmp/frpc.toml <<'EOF'
|
||||
serverAddr = "YOUR_FRP_SERVER_IP_OR_DOMAIN"
|
||||
serverPort = CHANGE_ME_FRP_SERVER_PORT
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "CHANGE_ME_FRP_TOKEN"
|
||||
|
||||
transport.poolCount = 5
|
||||
transport.heartbeatTimeout = -1
|
||||
|
||||
[[proxies]]
|
||||
name = "CHANGE_ME_PROXY_NAME"
|
||||
type = "tcp"
|
||||
localIP = "reactive_resume_app"
|
||||
localPort = 3000
|
||||
remotePort = CHANGE_ME_REMOTE_PORT
|
||||
EOF
|
||||
exec frpc -c /tmp/frpc.toml
|
||||
depends_on:
|
||||
reactive_resume_app:
|
||||
condition: service_healthy
|
||||
72
packages/reactive-resume-clean-install/compose.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
# Reactive Resume 通用服务器纯净模板。
|
||||
# 使用前请先复制 .env.example 为 .env,并填写域名、端口、数据库密码和密钥。
|
||||
|
||||
name: reactive-resume
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- resume_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
reactive-resume:
|
||||
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
# 待填写:LOCAL_BIND_IP / LOCAL_APP_PORT 在 .env 中配置。
|
||||
# 例:127.0.0.1:3004:3000,仅允许本机反代或 frpc 访问。
|
||||
- "${LOCAL_BIND_IP}:${LOCAL_APP_PORT}:3000"
|
||||
volumes:
|
||||
- reactive_resume_data:/app/data
|
||||
networks:
|
||||
- resume_net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1));",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
frpc:
|
||||
image: fatedier/frpc:v0.68.0
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/frp/frpc.yaml"]
|
||||
volumes:
|
||||
# 待填写:frpc.yaml 中配置 FRP 服务器、token、remotePort。
|
||||
- ./frpc.yaml:/etc/frp/frpc.yaml:ro
|
||||
networks:
|
||||
- resume_net
|
||||
depends_on:
|
||||
reactive-resume:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
resume_net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
reactive_resume_data:
|
||||
22
packages/reactive-resume-clean-install/frpc.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Reactive Resume frpc 纯净模板。
|
||||
# 使用前把所有 CHANGE_ME / YOUR_* 改成真实值。
|
||||
|
||||
serverAddr: "YOUR_FRP_SERVER_IP_OR_DOMAIN"
|
||||
serverPort: CHANGE_ME_FRP_SERVER_PORT
|
||||
|
||||
auth:
|
||||
method: "token"
|
||||
token: "CHANGE_ME_FRP_TOKEN"
|
||||
|
||||
transport:
|
||||
poolCount: 5
|
||||
heartbeatTimeout: -1
|
||||
|
||||
proxies:
|
||||
- name: "CHANGE_ME_PROXY_NAME"
|
||||
type: "tcp"
|
||||
# Docker Compose 内部服务名,通常不用改。
|
||||
localIP: "reactive-resume"
|
||||
localPort: 3000
|
||||
# 待填写:公网 FRP 远程端口,需与反向代理 forward port 一致。
|
||||
remotePort: CHANGE_ME_REMOTE_PORT
|
||||
50
packages/reactive-resume-personal-direct/.env
Normal file
@@ -0,0 +1,50 @@
|
||||
TZ=Asia/Shanghai
|
||||
APP_URL=https://me.huijutec.cn
|
||||
|
||||
# Local debug access only: http://127.0.0.1:3004
|
||||
LOCAL_BIND_IP=127.0.0.1
|
||||
LOCAL_APP_PORT=3004
|
||||
|
||||
POSTGRES_DB=reactive_resume
|
||||
POSTGRES_USER=reactive_resume
|
||||
POSTGRES_PASSWORD=2ed1869944c609f070699bdf8c92194f
|
||||
DATABASE_URL=postgresql://reactive_resume:2ed1869944c609f070699bdf8c92194f@postgres:5432/reactive_resume
|
||||
|
||||
AUTH_SECRET=9ef1720ee316f9316241bdc84f5dfad99b52f139b48880300942ee61d81b7cda
|
||||
ENCRYPTION_SECRET=20851888c2a96b11f1f6fc21b4eeb70f1e8258f2d0d414be8bace65eaff289ae
|
||||
|
||||
BETTER_AUTH_API_KEY=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
OAUTH_PROVIDER_NAME=
|
||||
OAUTH_CLIENT_ID=
|
||||
OAUTH_CLIENT_SECRET=
|
||||
OAUTH_DISCOVERY_URL=
|
||||
OAUTH_AUTHORIZATION_URL=
|
||||
OAUTH_TOKEN_URL=
|
||||
OAUTH_USER_INFO_URL=
|
||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=
|
||||
OAUTH_SCOPES=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=Reactive Resume <noreply@me.huijutec.cn>
|
||||
SMTP_SECURE=false
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_REGION=us-east-1
|
||||
S3_ENDPOINT=
|
||||
S3_BUCKET=
|
||||
S3_FORCE_PATH_STYLE=false
|
||||
REDIS_URL=
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
FLAG_DISABLE_SIGNUPS=false
|
||||
FLAG_DISABLE_EMAIL_AUTH=false
|
||||
FLAG_DISABLE_IMAGE_PROCESSING=false
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL=false
|
||||
36
packages/reactive-resume-personal-direct/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Reactive Resume 个人简历直接运行安装包
|
||||
|
||||
这套包已经按 `https://me.huijutec.cn` 和 FRP `remotePort = 10003` 预置,可以在当前服务器上直接运行。包内已包含当前简历初始化数据、头像和作品集图片,首次启动后可直接访问公开简历。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yml up -d
|
||||
```
|
||||
|
||||
启动后:
|
||||
|
||||
- 本机调试地址:`http://127.0.0.1:3004`
|
||||
- 公网访问地址:`https://me.huijutec.cn`
|
||||
- 当前公开简历:`https://me.huijutec.cn/audience/resume`
|
||||
- FRP 映射:本地 `reactive-resume:3000` -> 公网服务器 `10003`
|
||||
|
||||
## 反向代理要求
|
||||
|
||||
公网服务器上的 Nginx Proxy Manager / 反向代理应配置:
|
||||
|
||||
- Domain Names:`me.huijutec.cn`
|
||||
- Scheme:`http`
|
||||
- Forward Hostname / IP:`82.157.255.195`
|
||||
- Forward Port:`10003`
|
||||
- Websockets Support:开启
|
||||
- SSL:按现有 huijutec.cn 域名策略配置
|
||||
|
||||
## 数据
|
||||
|
||||
Compose 会创建独立项目名 `reactive-resume-personal`,默认使用 Docker named volumes:
|
||||
|
||||
- `reactive-resume-personal_postgres_data`
|
||||
- `reactive-resume-personal_reactive_resume_data`
|
||||
|
||||
`seed/` 目录会在首次启动时导入当前用户、公开简历和上传图片。`patches/` 目录会在应用启动时自动应用当前个人版需要的 PDF 渲染、Glalie 模板排版和“按简历标题下载 PDF”补丁。后续如需迁移数据,请备份这些 volumes。
|
||||
97
packages/reactive-resume-personal-direct/compose.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
name: reactive-resume-personal
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- resume_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
reactive-resume:
|
||||
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/sh", "/opt/reactive-resume-patches/reactive-resume-entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${LOCAL_BIND_IP}:${LOCAL_APP_PORT}:3000"
|
||||
volumes:
|
||||
- reactive_resume_data:/app/data
|
||||
- ./patches/reactive-resume-runtime-patch.sh:/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh:ro
|
||||
- ./patches/reactive-resume-entrypoint.sh:/opt/reactive-resume-patches/reactive-resume-entrypoint.sh:ro
|
||||
networks:
|
||||
- resume_net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1));",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
seed:
|
||||
image: postgres:16-alpine
|
||||
restart: "no"
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh"]
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
mkdir -p /app/data/uploads
|
||||
cp -a /seed/uploads/. /app/data/uploads/
|
||||
chown -R 1000:1000 /app/data/uploads || true
|
||||
psql -h postgres -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=1 -f /seed/seed.sql
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- reactive_resume_data:/app/data
|
||||
- ./seed:/seed:ro
|
||||
networks:
|
||||
- resume_net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
reactive-resume:
|
||||
condition: service_healthy
|
||||
|
||||
frpc:
|
||||
image: fatedier/frpc:v0.68.0
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/frp/frpc.yaml"]
|
||||
volumes:
|
||||
- ./frpc.yaml:/etc/frp/frpc.yaml:ro
|
||||
networks:
|
||||
- resume_net
|
||||
depends_on:
|
||||
seed:
|
||||
condition: service_completed_successfully
|
||||
|
||||
networks:
|
||||
resume_net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
reactive_resume_data:
|
||||
17
packages/reactive-resume-personal-direct/frpc.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
serverAddr: "82.157.255.195"
|
||||
serverPort: 7000
|
||||
|
||||
auth:
|
||||
method: "token"
|
||||
token: "en.xjtu.edu.cn"
|
||||
|
||||
transport:
|
||||
poolCount: 5
|
||||
heartbeatTimeout: -1
|
||||
|
||||
proxies:
|
||||
- name: "reactive-resume-personal"
|
||||
type: "tcp"
|
||||
localIP: "reactive-resume"
|
||||
localPort: 3000
|
||||
remotePort: 10003
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PATCH_SCRIPT="/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh"
|
||||
|
||||
if [ -f "$PATCH_SCRIPT" ]; then
|
||||
sh "$PATCH_SCRIPT" || echo "Reactive Resume runtime patch failed, continuing with the image default startup" >&2
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
if [ -f /app/apps/server/dist/index.mjs ]; then
|
||||
cd /app
|
||||
set -- node apps/server/dist/index.mjs
|
||||
elif [ -f /app/apps/web/.output/server/index.mjs ]; then
|
||||
cd /app/apps/web
|
||||
set -- node .output/server/index.mjs
|
||||
else
|
||||
server_entry="$(cat /tmp/reactive-resume-server-entry 2>/dev/null || true)"
|
||||
if [ -n "$server_entry" ] && [ -f "$server_entry" ]; then
|
||||
cd "$(dirname "$server_entry")"
|
||||
set -- node "$(basename "$server_entry")"
|
||||
else
|
||||
echo "Reactive Resume startup failed: no known server entry found" >&2
|
||||
find /app -maxdepth 5 \( -name index.mjs -o -name server.js -o -name main.js \) 2>/dev/null | head -50 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v docker-entrypoint.sh >/dev/null 2>&1; then
|
||||
exec docker-entrypoint.sh "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_DIR="${REACTIVE_RESUME_APP_DIR:-}"
|
||||
SERVER_ENTRY=""
|
||||
ASSETS_DIR=""
|
||||
SSR_DIR=""
|
||||
SERVER_INDEX_FILE=""
|
||||
SSR_FILE=""
|
||||
|
||||
if [ -z "$APP_DIR" ]; then
|
||||
for candidate in /app/apps/web /app; do
|
||||
if [ -f "$candidate/.output/server/index.mjs" ]; then
|
||||
APP_DIR="$candidate"
|
||||
SERVER_ENTRY="$candidate/.output/server/index.mjs"
|
||||
ASSETS_DIR="$candidate/.output/public/assets"
|
||||
SSR_DIR="$candidate/.output/server/_ssr"
|
||||
SERVER_INDEX_FILE="$SERVER_ENTRY"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$SERVER_ENTRY" ]; then
|
||||
index_file="$(find /app -path "*/.output/server/index.mjs" -type f 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$index_file" ]; then
|
||||
APP_DIR="${index_file%/.output/server/index.mjs}"
|
||||
SERVER_ENTRY="$index_file"
|
||||
ASSETS_DIR="$APP_DIR/.output/public/assets"
|
||||
SSR_DIR="$APP_DIR/.output/server/_ssr"
|
||||
SERVER_INDEX_FILE="$SERVER_ENTRY"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$SERVER_ENTRY" ] && [ -f /app/apps/server/dist/index.mjs ]; then
|
||||
APP_DIR="/app"
|
||||
SERVER_ENTRY="/app/apps/server/dist/index.mjs"
|
||||
ASSETS_DIR="/app/apps/web/dist/assets"
|
||||
SERVER_INDEX_FILE="$SERVER_ENTRY"
|
||||
SSR_FILE="$SERVER_ENTRY"
|
||||
fi
|
||||
|
||||
if [ -z "$SERVER_ENTRY" ] || [ ! -f "$SERVER_ENTRY" ]; then
|
||||
echo "Reactive Resume runtime patch skipped: server entry not found under /app" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf "%s" "$APP_DIR" > /tmp/reactive-resume-app-dir
|
||||
printf "%s" "$SERVER_ENTRY" > /tmp/reactive-resume-server-entry
|
||||
export APP_DIR ASSETS_DIR SSR_DIR SERVER_INDEX_FILE SSR_FILE SERVER_ENTRY
|
||||
|
||||
node - <<'NODE'
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const appDir = process.env.APP_DIR || "/app";
|
||||
const outputDir = path.join(appDir, ".output");
|
||||
const assetsDir = process.env.ASSETS_DIR || path.join(outputDir, "public/assets");
|
||||
const ssrDir = process.env.SSR_DIR || "";
|
||||
const explicitSsrFile = process.env.SSR_FILE || "";
|
||||
const serverIndexFile = process.env.SERVER_INDEX_FILE || path.join(outputDir, "server/index.mjs");
|
||||
const filenameCacheBust = "rr-filename-title-20260520b";
|
||||
const pdfCacheBust = "rr-glalie-layout-20260520";
|
||||
const appShellSuffix = "rr20260520c";
|
||||
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
|
||||
const serviceWorkerCleanup = `/* Reactive Resume personal deployment: disable stale PWA caches. */
|
||||
self.addEventListener("install", (event) => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
|
||||
});
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
await caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
self.addEventListener("fetch", () => {});
|
||||
`;
|
||||
|
||||
function warn(message) {
|
||||
console.warn(`Reactive Resume runtime patch: ${message}`);
|
||||
}
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function write(file, source) {
|
||||
fs.writeFileSync(file, source);
|
||||
}
|
||||
|
||||
function makeEtag(buffer) {
|
||||
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
|
||||
return `"${buffer.length.toString(16)}-${digest}"`;
|
||||
}
|
||||
|
||||
function patchStaticManifestEntry(source, urlPath, filePath) {
|
||||
if (!fs.existsSync(filePath)) return source;
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const startMarker = `"${urlPath}": {`;
|
||||
const start = source.indexOf(startMarker);
|
||||
if (start === -1) return source;
|
||||
const commaEnd = source.indexOf("\n\t},", start);
|
||||
const objectEnd = source.indexOf("\n\t}", start);
|
||||
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
|
||||
if (end === -1) return source;
|
||||
|
||||
let entry = source.slice(start, end);
|
||||
entry = entry
|
||||
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
|
||||
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
|
||||
.replace(/"size": \d+/, `"size": ${buffer.length}`);
|
||||
return source.slice(0, start) + entry + source.slice(end);
|
||||
}
|
||||
|
||||
function escapeRegex(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function listJsFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".js"))
|
||||
.map((name) => path.join(dir, name));
|
||||
}
|
||||
|
||||
function listFilesRecursive(dir, predicate) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const result = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...listFilesRecursive(file, predicate));
|
||||
} else if (predicate(file)) {
|
||||
result.push(file);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceOnce(source, from, to) {
|
||||
return source.includes(to) ? source : source.replace(from, to);
|
||||
}
|
||||
|
||||
function replaceRegexOnce(source, regex, to) {
|
||||
return source.includes(to) ? source : source.replace(regex, to);
|
||||
}
|
||||
|
||||
function patchFilenameBundle(file) {
|
||||
if (!/^file-[A-Za-z0-9_-]+\.js$/.test(path.basename(file))) {
|
||||
return false;
|
||||
}
|
||||
let source = read(file);
|
||||
const replacement = 'function t(e,t){let n=(e??"resume").toString().trim()||"resume",r=t==null?"":String(t).trim().replace(/^\\./,"");return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",r&&n.toLowerCase().endsWith("."+r.toLowerCase())?n:`${n}${r?`.${r}`:""}`}';
|
||||
if (source.includes(replacement)) return true;
|
||||
|
||||
const start = source.indexOf("function t(");
|
||||
const end = source.indexOf("function n(", start);
|
||||
if (start === -1 || end === -1) {
|
||||
warn(`filename bundle marker not found in ${path.basename(file)}, skipped`);
|
||||
return false;
|
||||
}
|
||||
|
||||
source = source.slice(0, start) + replacement + source.slice(end);
|
||||
write(file, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
function patchSsr(source) {
|
||||
source = source.replace(/\n\t\tname: "",\n\t\tdata: \{/, "\n\t\tname: resume.name,\n\t\tdata: {");
|
||||
|
||||
const filenameReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix ?? "resume").toString().trim() || "resume";\n\tlet ext = extension == null ? "" : String(extension).trim().replace(/^\\./, "");\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn ext && filename.toLowerCase().endsWith(\`.\${ext.toLowerCase()}\`) ? filename : \`\${filename}\${ext ? \`.\${ext}\` : ""}\`;\n}`;
|
||||
if (!source.includes(filenameReplacement)) {
|
||||
const slugifiedPattern = /function generateFilename\(prefix, extension\) \{\s*return `\$\{slugify\(prefix\)\}\$\{extension \? `\.\$\{extension\}` : ""\}`;\s*\}/;
|
||||
if (slugifiedPattern.test(source)) {
|
||||
source = source.replace(slugifiedPattern, filenameReplacement);
|
||||
} else {
|
||||
const start = source.indexOf("function generateFilename(");
|
||||
const end = source.indexOf("\nfunction downloadWithAnchor(", start);
|
||||
if (start !== -1 && end !== -1) {
|
||||
source = source.slice(0, start) + filenameReplacement + source.slice(end);
|
||||
} else {
|
||||
warn("SSR generateFilename marker not found, skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source = source
|
||||
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
|
||||
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(3.0) }),")
|
||||
.replace(/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(3.0) }),")
|
||||
.replace(/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/, "sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},");
|
||||
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
|
||||
"sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},\n\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },\n\t\t\t\titem: { rowGap: metrics.gapY(.2) },",
|
||||
);
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
|
||||
"sidebarColumn: {\n\t\t\t\t\tzIndex: 1,\n\t\t\t\t\tbackgroundColor: primaryTint,\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,\n\t\t\t\t\trowGap: metrics.gapY(3.0)\n\t\t\t\t},",
|
||||
);
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
|
||||
"mainContent: {\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical\n\t\t\t\t},",
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
function patchAppManifest(file, oldBase, newBase) {
|
||||
if (!fs.existsSync(file)) return false;
|
||||
let source = read(file);
|
||||
const next = source.replace(new RegExp(`/assets/${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `/assets/${newBase}`);
|
||||
if (next !== source) {
|
||||
write(file, next);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function cloneStaticManifestEntry(source, oldUrl, newUrl, oldBase, newBase, newFile) {
|
||||
const marker = `\t"${oldUrl}": {`;
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
warn(`static manifest entry not found for ${oldUrl}, skipped app shell cache bust`);
|
||||
return source;
|
||||
}
|
||||
|
||||
if (source.includes(`\t"${newUrl}": {`)) {
|
||||
return patchStaticManifestEntry(source, newUrl, newFile);
|
||||
}
|
||||
|
||||
const close = source.indexOf("\n\t}", start);
|
||||
if (close === -1 || source[close + 3] !== ",") {
|
||||
warn(`static manifest entry close marker not found for ${oldUrl}, skipped app shell cache bust`);
|
||||
return source;
|
||||
}
|
||||
|
||||
let entry = source.slice(start, close + 3);
|
||||
const buffer = fs.readFileSync(newFile);
|
||||
entry = entry
|
||||
.replace(oldUrl, newUrl)
|
||||
.replace(`../public/assets/${oldBase}`, `../public/assets/${newBase}`)
|
||||
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
|
||||
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
|
||||
.replace(/"size": \d+/, `"size": ${buffer.length}`);
|
||||
|
||||
return source.slice(0, close + 4) + "\n" + entry + "," + source.slice(close + 4);
|
||||
}
|
||||
|
||||
function patchAppShellEntry(assetFiles) {
|
||||
const indexFile = assetFiles.find((file) => /^index-[A-Za-z0-9_-]+\.js$/.test(path.basename(file)));
|
||||
if (!indexFile) {
|
||||
warn("index app shell bundle not found, skipped app shell cache bust");
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldBase = path.basename(indexFile);
|
||||
const newBase = oldBase.replace(/\.js$/, `-${appShellSuffix}.js`);
|
||||
const newFile = path.join(path.dirname(indexFile), newBase);
|
||||
if (!fs.existsSync(newFile)) {
|
||||
fs.copyFileSync(indexFile, newFile);
|
||||
}
|
||||
return { oldBase, newBase, newFile };
|
||||
}
|
||||
|
||||
function patchAppShellImporters(assetFiles, oldBase, newBase) {
|
||||
const touched = [];
|
||||
for (const file of assetFiles) {
|
||||
if (path.basename(file) === oldBase) continue;
|
||||
let source = read(file);
|
||||
let next = source
|
||||
.replace(new RegExp(`\\./${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `./${newBase}`)
|
||||
.replace(new RegExp(`/assets/${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `/assets/${newBase}`);
|
||||
if (next !== source) {
|
||||
write(file, next);
|
||||
touched.push(file);
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
function patchPublicPdf(source) {
|
||||
if (!source.includes("rr-browser-buffer-polyfill")) {
|
||||
const importPrelude = source.match(/^(?:import[^;]+;)+/);
|
||||
const insertAt = importPrelude ? importPrelude[0].length : source.indexOf(";") + 1;
|
||||
if (insertAt > 0 && source.startsWith("import")) {
|
||||
source = source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
|
||||
} else {
|
||||
warn("PDF bundle import prelude not found, Buffer shim skipped");
|
||||
}
|
||||
}
|
||||
|
||||
source = source
|
||||
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/, "$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
|
||||
.replace(/[oc]\.gapY\(3\.5\)/g, (m) => `${m[0]}.gapY(3.0)`)
|
||||
.replace(/[oc]\.gapY\(2\.6\)/g, (m) => `${m[0]}.gapY(3.0)`)
|
||||
.replace(/[oc]\.gapY\(2\.2\)/g, (m) => `${m[0]}.gapY(3.0)`)
|
||||
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
|
||||
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})")
|
||||
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}")
|
||||
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}");
|
||||
|
||||
source = replaceOnce(source, "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}", "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}");
|
||||
source = replaceOnce(source, "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}", "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}");
|
||||
return source;
|
||||
}
|
||||
|
||||
const assetFiles = listJsFiles(assetsDir);
|
||||
const filenameFiles = assetFiles
|
||||
.filter((file) => {
|
||||
if (!/^file-[A-Za-z0-9_-]+\.js$/.test(path.basename(file))) return false;
|
||||
const source = read(file);
|
||||
return source.includes("URL.createObjectURL") && source.includes(".download") && source.includes("revokeObjectURL");
|
||||
})
|
||||
.sort((a, b) => fs.statSync(a).size - fs.statSync(b).size);
|
||||
|
||||
const patchedFilenameFiles = [];
|
||||
for (const file of filenameFiles) {
|
||||
if (patchFilenameBundle(file)) patchedFilenameFiles.push(file);
|
||||
}
|
||||
if (patchedFilenameFiles.length === 0) warn("no filename bundle patched");
|
||||
|
||||
let ssrFile = "";
|
||||
if (explicitSsrFile && fs.existsSync(explicitSsrFile) && read(explicitSsrFile).includes("function generateFilename(")) {
|
||||
ssrFile = explicitSsrFile;
|
||||
} else if (ssrDir && fs.existsSync(ssrDir)) {
|
||||
ssrFile = fs.readdirSync(ssrDir)
|
||||
.filter((name) => name.endsWith(".mjs"))
|
||||
.map((name) => path.join(ssrDir, name))
|
||||
.find((file) => read(file).includes("function generateFilename(")) || "";
|
||||
}
|
||||
if (ssrFile) {
|
||||
write(ssrFile, patchSsr(read(ssrFile)));
|
||||
} else {
|
||||
warn("SSR bundle with generateFilename not found");
|
||||
}
|
||||
|
||||
const appShell = patchAppShellEntry(assetFiles);
|
||||
const appShellTouchedImporters = appShell ? patchAppShellImporters(assetFiles, appShell.oldBase, appShell.newBase) : [];
|
||||
if (appShell) {
|
||||
const appManifestFiles = [
|
||||
...listFilesRecursive(path.join(outputDir, "server"), (file) => path.basename(file).startsWith("_tanstack-start-manifest") && file.endsWith(".mjs")),
|
||||
...listFilesRecursive(path.join(appDir, "apps/server/dist"), (file) => path.basename(file).startsWith("_tanstack-start-manifest") && file.endsWith(".mjs")),
|
||||
];
|
||||
for (const file of appManifestFiles) patchAppManifest(file, appShell.oldBase, appShell.newBase);
|
||||
}
|
||||
|
||||
const pdfFile = assetFiles
|
||||
.filter((file) => path.basename(file).startsWith("pdf-document-"))
|
||||
.sort((a, b) => fs.statSync(b).size - fs.statSync(a).size)[0] || "";
|
||||
if (pdfFile) {
|
||||
write(pdfFile, patchPublicPdf(read(pdfFile)));
|
||||
} else {
|
||||
warn("public PDF bundle not found");
|
||||
}
|
||||
|
||||
const filenameBases = patchedFilenameFiles.map((file) => path.basename(file));
|
||||
const pdfBase = pdfFile ? path.basename(pdfFile) : "";
|
||||
const touchedImporters = [];
|
||||
for (const file of assetFiles) {
|
||||
let source = read(file);
|
||||
let next = source;
|
||||
for (const base of filenameBases) {
|
||||
next = next.replace(new RegExp(`\\./${escapeRegex(base)}(?:\\?v=rr-filename-[A-Za-z0-9-]+)?`, "g"), `./${base}?v=${filenameCacheBust}`);
|
||||
}
|
||||
if (pdfBase) {
|
||||
next = next.replace(new RegExp(`\\./${escapeRegex(pdfBase)}(?:\\?v=rr-[^"'\\\`]+)?`, "g"), `./${pdfBase}?v=${pdfCacheBust}`);
|
||||
}
|
||||
if (next !== source) {
|
||||
write(file, next);
|
||||
touchedImporters.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(serverIndexFile)) {
|
||||
let serverIndex = read(serverIndexFile);
|
||||
for (const file of [...patchedFilenameFiles, pdfFile, ...touchedImporters, ...appShellTouchedImporters].filter(Boolean)) {
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, `/assets/${path.basename(file)}`, file);
|
||||
}
|
||||
if (appShell) {
|
||||
serverIndex = cloneStaticManifestEntry(
|
||||
serverIndex,
|
||||
`/assets/${appShell.oldBase}`,
|
||||
`/assets/${appShell.newBase}`,
|
||||
appShell.oldBase,
|
||||
appShell.newBase,
|
||||
appShell.newFile,
|
||||
);
|
||||
}
|
||||
const serviceWorkerFile = path.join(path.dirname(assetsDir), "sw.js");
|
||||
if (fs.existsSync(serviceWorkerFile)) {
|
||||
write(serviceWorkerFile, serviceWorkerCleanup);
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, "/sw.js", serviceWorkerFile);
|
||||
}
|
||||
write(serverIndexFile, serverIndex);
|
||||
}
|
||||
|
||||
for (const file of [...patchedFilenameFiles, pdfFile, ssrFile, serverIndexFile].filter(Boolean)) {
|
||||
try {
|
||||
new Function(read(file));
|
||||
} catch {
|
||||
// ESM/import bundles are validated by Node at application startup; keep this
|
||||
// best-effort so a harmless syntax-check limitation never blocks boot.
|
||||
}
|
||||
}
|
||||
NODE
|
||||
1250
packages/reactive-resume-personal-direct/seed/resume-data.json
Normal file
1326
packages/reactive-resume-personal-direct/seed/seed.sql
Normal file
|
After Width: | Height: | Size: 6.4 MiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 97 KiB |
39
packages/reactive-resume-personal-qnap-nas/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Reactive Resume 个人简历 / QNAP NAS 安装包
|
||||
|
||||
这套包面向威联通 QNAP QTS / Container Station,已按以下参数预置:
|
||||
|
||||
- NAS 数据目录:`/share/Container/reactive_resume`
|
||||
- 本地端口:`3003:3000`
|
||||
- 公网域名:`https://me.huijutec.cn`
|
||||
- 当前公开简历:`https://me.huijutec.cn/audience/resume`
|
||||
- FRP 服务器:`82.157.255.195:7000`
|
||||
- FRP remotePort:`10003`
|
||||
- 容器运行时代理:`HTTP_PROXY=http://192.168.3.12:7893`、`HTTPS_PROXY=http://192.168.3.12:7893`,并已配置 `NO_PROXY` 绕过局域网和内部服务名
|
||||
|
||||
包内已包含当前简历初始化数据、头像和作品集图片,首次启动后会由 `reactive_resume_seed` 自动导入。
|
||||
|
||||
## 部署
|
||||
|
||||
1. 将本安装包内容放到 `/share/Container/reactive_resume`
|
||||
2. 打开 Container Station,导入 `/share/Container/reactive_resume/compose-Nas.yml`
|
||||
3. 启动项目
|
||||
4. 访问 `https://me.huijutec.cn/audience/resume`
|
||||
|
||||
发布归档中的顶层目录已经命名为 `reactive_resume/`,正常解压到 `/share/Container/` 下即可匹配上述路径。
|
||||
|
||||
## 反向代理要求
|
||||
|
||||
公网服务器上的 Nginx Proxy Manager / 反向代理应配置:
|
||||
|
||||
- Domain Names:`me.huijutec.cn`
|
||||
- Scheme:`http`
|
||||
- Forward Hostname / IP:`82.157.255.195`
|
||||
- Forward Port:`10003`
|
||||
- Websockets Support:开启
|
||||
|
||||
## 数据目录
|
||||
|
||||
- PostgreSQL:`/share/Container/reactive_resume/data/postgres`
|
||||
- 上传与本地存储:`/share/Container/reactive_resume/data/uploads`
|
||||
- 初始化种子:`/share/Container/reactive_resume/seed`
|
||||
- 运行时补丁:`/share/Container/reactive_resume/patches`
|
||||
180
packages/reactive-resume-personal-qnap-nas/compose-Nas.yml
Normal file
@@ -0,0 +1,180 @@
|
||||
# Reactive Resume 个人简历 / QNAP QTS 直接部署版。
|
||||
# 本文件已按 /share/Container/reactive_resume、
|
||||
# https://me.huijutec.cn、192.168.31.5:3003 本地访问、
|
||||
# frpc 公网映射 82.157.255.195:10003 预置。
|
||||
|
||||
name: reactive-resume-personal-nas
|
||||
|
||||
services:
|
||||
reactive_resume_permissions:
|
||||
image: alpine:3.20
|
||||
restart: "no"
|
||||
environment:
|
||||
HTTP_PROXY: http://192.168.3.12:7893
|
||||
HTTPS_PROXY: http://192.168.3.12:7893
|
||||
http_proxy: http://192.168.3.12:7893
|
||||
https_proxy: http://192.168.3.12:7893
|
||||
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
command: ["sh", "-c", "mkdir -p /app/data && chown -R 1000:1000 /app/data"]
|
||||
volumes:
|
||||
- /share/Container/reactive_resume/data/uploads:/app/data
|
||||
|
||||
reactive_resume_db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HTTP_PROXY: http://192.168.3.12:7893
|
||||
HTTPS_PROXY: http://192.168.3.12:7893
|
||||
http_proxy: http://192.168.3.12:7893
|
||||
https_proxy: http://192.168.3.12:7893
|
||||
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
POSTGRES_DB: reactive_resume
|
||||
POSTGRES_USER: reactive_resume
|
||||
POSTGRES_PASSWORD: 5b341c0ca29fefd6d648661150c00fa4
|
||||
volumes:
|
||||
- /share/Container/reactive_resume/data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U reactive_resume -d reactive_resume"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
reactive_resume_app:
|
||||
image: amruthpillai/reactive-resume@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/sh", "/opt/reactive-resume-patches/reactive-resume-entrypoint.sh"]
|
||||
depends_on:
|
||||
reactive_resume_permissions:
|
||||
condition: service_completed_successfully
|
||||
reactive_resume_db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3003:3000"
|
||||
volumes:
|
||||
- /share/Container/reactive_resume/data/uploads:/app/data
|
||||
- /share/Container/reactive_resume/patches/reactive-resume-runtime-patch.sh:/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh:ro
|
||||
- /share/Container/reactive_resume/patches/reactive-resume-entrypoint.sh:/opt/reactive-resume-patches/reactive-resume-entrypoint.sh:ro
|
||||
environment:
|
||||
HTTP_PROXY: http://192.168.3.12:7893
|
||||
HTTPS_PROXY: http://192.168.3.12:7893
|
||||
http_proxy: http://192.168.3.12:7893
|
||||
https_proxy: http://192.168.3.12:7893
|
||||
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
TZ: Asia/Shanghai
|
||||
APP_URL: https://me.huijutec.cn
|
||||
DATABASE_URL: postgresql://reactive_resume:5b341c0ca29fefd6d648661150c00fa4@reactive_resume_db:5432/reactive_resume
|
||||
AUTH_SECRET: c76b0eaf79f731e9ee95918dc69d41696aec9d1deffeabc122944898037bfab1
|
||||
ENCRYPTION_SECRET: df3a460fa2f92f6e8765927a169322980e18f63a88fbcfedb090819b5afb2408
|
||||
|
||||
BETTER_AUTH_API_KEY: ""
|
||||
GOOGLE_CLIENT_ID: ""
|
||||
GOOGLE_CLIENT_SECRET: ""
|
||||
GITHUB_CLIENT_ID: ""
|
||||
GITHUB_CLIENT_SECRET: ""
|
||||
LINKEDIN_CLIENT_ID: ""
|
||||
LINKEDIN_CLIENT_SECRET: ""
|
||||
OAUTH_PROVIDER_NAME: ""
|
||||
OAUTH_CLIENT_ID: ""
|
||||
OAUTH_CLIENT_SECRET: ""
|
||||
OAUTH_DISCOVERY_URL: ""
|
||||
OAUTH_AUTHORIZATION_URL: ""
|
||||
OAUTH_TOKEN_URL: ""
|
||||
OAUTH_USER_INFO_URL: ""
|
||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS: ""
|
||||
OAUTH_SCOPES: ""
|
||||
SMTP_HOST: ""
|
||||
SMTP_PORT: "587"
|
||||
SMTP_USER: ""
|
||||
SMTP_PASS: ""
|
||||
SMTP_FROM: "Reactive Resume <noreply@me.huijutec.cn>"
|
||||
SMTP_SECURE: "false"
|
||||
S3_ACCESS_KEY_ID: ""
|
||||
S3_SECRET_ACCESS_KEY: ""
|
||||
S3_REGION: us-east-1
|
||||
S3_ENDPOINT: ""
|
||||
S3_BUCKET: ""
|
||||
S3_FORCE_PATH_STYLE: "false"
|
||||
REDIS_URL: ""
|
||||
CLOUDFLARE_ACCOUNT_ID: ""
|
||||
CLOUDFLARE_API_TOKEN: ""
|
||||
FLAG_DISABLE_SIGNUPS: "false"
|
||||
FLAG_DISABLE_EMAIL_AUTH: "false"
|
||||
FLAG_DISABLE_IMAGE_PROCESSING: "false"
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: "false"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 8
|
||||
start_period: 30s
|
||||
|
||||
reactive_resume_seed:
|
||||
image: postgres:16-alpine
|
||||
restart: "no"
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh"]
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
mkdir -p /app/data/uploads
|
||||
cp -a /seed/uploads/. /app/data/uploads/
|
||||
chown -R 1000:1000 /app/data/uploads || true
|
||||
psql -h reactive_resume_db -U reactive_resume -d reactive_resume -v ON_ERROR_STOP=1 -f /seed/seed.sql
|
||||
environment:
|
||||
HTTP_PROXY: http://192.168.3.12:7893
|
||||
HTTPS_PROXY: http://192.168.3.12:7893
|
||||
http_proxy: http://192.168.3.12:7893
|
||||
https_proxy: http://192.168.3.12:7893
|
||||
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
PGPASSWORD: 5b341c0ca29fefd6d648661150c00fa4
|
||||
volumes:
|
||||
- /share/Container/reactive_resume/data/uploads:/app/data
|
||||
- /share/Container/reactive_resume/seed:/seed:ro
|
||||
depends_on:
|
||||
reactive_resume_permissions:
|
||||
condition: service_completed_successfully
|
||||
reactive_resume_db:
|
||||
condition: service_healthy
|
||||
reactive_resume_app:
|
||||
condition: service_healthy
|
||||
|
||||
reactive_resume_frpc:
|
||||
image: fatedier/frpc:v0.68.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HTTP_PROXY: http://192.168.3.12:7893
|
||||
HTTPS_PROXY: http://192.168.3.12:7893
|
||||
http_proxy: http://192.168.3.12:7893
|
||||
https_proxy: http://192.168.3.12:7893
|
||||
NO_PROXY: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
no_proxy: localhost,127.0.0.1,192.168.0.0/16,reactive_resume_db,reactive_resume_app,reactive_resume_seed,reactive_resume_frpc
|
||||
entrypoint: ["/bin/sh"]
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
cat > /tmp/frpc.toml <<'EOF'
|
||||
serverAddr = "82.157.255.195"
|
||||
serverPort = 7000
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "en.xjtu.edu.cn"
|
||||
|
||||
transport.poolCount = 5
|
||||
transport.heartbeatTimeout = -1
|
||||
|
||||
[[proxies]]
|
||||
name = "Reactive_Resume_NAS"
|
||||
type = "tcp"
|
||||
localIP = "reactive_resume_app"
|
||||
localPort = 3000
|
||||
remotePort = 10003
|
||||
EOF
|
||||
exec frpc -c /tmp/frpc.toml
|
||||
depends_on:
|
||||
reactive_resume_seed:
|
||||
condition: service_completed_successfully
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PATCH_SCRIPT="/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh"
|
||||
|
||||
if [ -f "$PATCH_SCRIPT" ]; then
|
||||
sh "$PATCH_SCRIPT" || echo "Reactive Resume runtime patch failed, continuing with the image default startup" >&2
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
if [ -f /app/apps/server/dist/index.mjs ]; then
|
||||
cd /app
|
||||
set -- node apps/server/dist/index.mjs
|
||||
elif [ -f /app/apps/web/.output/server/index.mjs ]; then
|
||||
cd /app/apps/web
|
||||
set -- node .output/server/index.mjs
|
||||
else
|
||||
server_entry="$(cat /tmp/reactive-resume-server-entry 2>/dev/null || true)"
|
||||
if [ -n "$server_entry" ] && [ -f "$server_entry" ]; then
|
||||
cd "$(dirname "$server_entry")"
|
||||
set -- node "$(basename "$server_entry")"
|
||||
else
|
||||
echo "Reactive Resume startup failed: no known server entry found" >&2
|
||||
find /app -maxdepth 5 \( -name index.mjs -o -name server.js -o -name main.js \) 2>/dev/null | head -50 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v docker-entrypoint.sh >/dev/null 2>&1; then
|
||||
exec docker-entrypoint.sh "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_DIR="${REACTIVE_RESUME_APP_DIR:-}"
|
||||
SERVER_ENTRY=""
|
||||
ASSETS_DIR=""
|
||||
SSR_DIR=""
|
||||
SERVER_INDEX_FILE=""
|
||||
SSR_FILE=""
|
||||
|
||||
if [ -z "$APP_DIR" ]; then
|
||||
for candidate in /app/apps/web /app; do
|
||||
if [ -f "$candidate/.output/server/index.mjs" ]; then
|
||||
APP_DIR="$candidate"
|
||||
SERVER_ENTRY="$candidate/.output/server/index.mjs"
|
||||
ASSETS_DIR="$candidate/.output/public/assets"
|
||||
SSR_DIR="$candidate/.output/server/_ssr"
|
||||
SERVER_INDEX_FILE="$SERVER_ENTRY"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$SERVER_ENTRY" ]; then
|
||||
index_file="$(find /app -path "*/.output/server/index.mjs" -type f 2>/dev/null | head -n 1 || true)"
|
||||
if [ -n "$index_file" ]; then
|
||||
APP_DIR="${index_file%/.output/server/index.mjs}"
|
||||
SERVER_ENTRY="$index_file"
|
||||
ASSETS_DIR="$APP_DIR/.output/public/assets"
|
||||
SSR_DIR="$APP_DIR/.output/server/_ssr"
|
||||
SERVER_INDEX_FILE="$SERVER_ENTRY"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$SERVER_ENTRY" ] && [ -f /app/apps/server/dist/index.mjs ]; then
|
||||
APP_DIR="/app"
|
||||
SERVER_ENTRY="/app/apps/server/dist/index.mjs"
|
||||
ASSETS_DIR="/app/apps/web/dist/assets"
|
||||
SERVER_INDEX_FILE="$SERVER_ENTRY"
|
||||
SSR_FILE="$SERVER_ENTRY"
|
||||
fi
|
||||
|
||||
if [ -z "$SERVER_ENTRY" ] || [ ! -f "$SERVER_ENTRY" ]; then
|
||||
echo "Reactive Resume runtime patch skipped: server entry not found under /app" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf "%s" "$APP_DIR" > /tmp/reactive-resume-app-dir
|
||||
printf "%s" "$SERVER_ENTRY" > /tmp/reactive-resume-server-entry
|
||||
export APP_DIR ASSETS_DIR SSR_DIR SERVER_INDEX_FILE SSR_FILE SERVER_ENTRY
|
||||
|
||||
node - <<'NODE'
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const appDir = process.env.APP_DIR || "/app";
|
||||
const outputDir = path.join(appDir, ".output");
|
||||
const assetsDir = process.env.ASSETS_DIR || path.join(outputDir, "public/assets");
|
||||
const ssrDir = process.env.SSR_DIR || "";
|
||||
const explicitSsrFile = process.env.SSR_FILE || "";
|
||||
const serverIndexFile = process.env.SERVER_INDEX_FILE || path.join(outputDir, "server/index.mjs");
|
||||
const filenameCacheBust = "rr-filename-title-20260520b";
|
||||
const pdfCacheBust = "rr-glalie-layout-20260520";
|
||||
const appShellSuffix = "rr20260520c";
|
||||
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
|
||||
const serviceWorkerCleanup = `/* Reactive Resume personal deployment: disable stale PWA caches. */
|
||||
self.addEventListener("install", (event) => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
|
||||
});
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
await caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
self.addEventListener("fetch", () => {});
|
||||
`;
|
||||
|
||||
function warn(message) {
|
||||
console.warn(`Reactive Resume runtime patch: ${message}`);
|
||||
}
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function write(file, source) {
|
||||
fs.writeFileSync(file, source);
|
||||
}
|
||||
|
||||
function makeEtag(buffer) {
|
||||
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
|
||||
return `"${buffer.length.toString(16)}-${digest}"`;
|
||||
}
|
||||
|
||||
function patchStaticManifestEntry(source, urlPath, filePath) {
|
||||
if (!fs.existsSync(filePath)) return source;
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const startMarker = `"${urlPath}": {`;
|
||||
const start = source.indexOf(startMarker);
|
||||
if (start === -1) return source;
|
||||
const commaEnd = source.indexOf("\n\t},", start);
|
||||
const objectEnd = source.indexOf("\n\t}", start);
|
||||
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
|
||||
if (end === -1) return source;
|
||||
|
||||
let entry = source.slice(start, end);
|
||||
entry = entry
|
||||
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
|
||||
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
|
||||
.replace(/"size": \d+/, `"size": ${buffer.length}`);
|
||||
return source.slice(0, start) + entry + source.slice(end);
|
||||
}
|
||||
|
||||
function escapeRegex(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function listJsFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".js"))
|
||||
.map((name) => path.join(dir, name));
|
||||
}
|
||||
|
||||
function listFilesRecursive(dir, predicate) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const result = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...listFilesRecursive(file, predicate));
|
||||
} else if (predicate(file)) {
|
||||
result.push(file);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function replaceOnce(source, from, to) {
|
||||
return source.includes(to) ? source : source.replace(from, to);
|
||||
}
|
||||
|
||||
function replaceRegexOnce(source, regex, to) {
|
||||
return source.includes(to) ? source : source.replace(regex, to);
|
||||
}
|
||||
|
||||
function patchFilenameBundle(file) {
|
||||
if (!/^file-[A-Za-z0-9_-]+\.js$/.test(path.basename(file))) {
|
||||
return false;
|
||||
}
|
||||
let source = read(file);
|
||||
const replacement = 'function t(e,t){let n=(e??"resume").toString().trim()||"resume",r=t==null?"":String(t).trim().replace(/^\\./,"");return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",r&&n.toLowerCase().endsWith("."+r.toLowerCase())?n:`${n}${r?`.${r}`:""}`}';
|
||||
if (source.includes(replacement)) return true;
|
||||
|
||||
const start = source.indexOf("function t(");
|
||||
const end = source.indexOf("function n(", start);
|
||||
if (start === -1 || end === -1) {
|
||||
warn(`filename bundle marker not found in ${path.basename(file)}, skipped`);
|
||||
return false;
|
||||
}
|
||||
|
||||
source = source.slice(0, start) + replacement + source.slice(end);
|
||||
write(file, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
function patchSsr(source) {
|
||||
source = source.replace(/\n\t\tname: "",\n\t\tdata: \{/, "\n\t\tname: resume.name,\n\t\tdata: {");
|
||||
|
||||
const filenameReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix ?? "resume").toString().trim() || "resume";\n\tlet ext = extension == null ? "" : String(extension).trim().replace(/^\\./, "");\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn ext && filename.toLowerCase().endsWith(\`.\${ext.toLowerCase()}\`) ? filename : \`\${filename}\${ext ? \`.\${ext}\` : ""}\`;\n}`;
|
||||
if (!source.includes(filenameReplacement)) {
|
||||
const slugifiedPattern = /function generateFilename\(prefix, extension\) \{\s*return `\$\{slugify\(prefix\)\}\$\{extension \? `\.\$\{extension\}` : ""\}`;\s*\}/;
|
||||
if (slugifiedPattern.test(source)) {
|
||||
source = source.replace(slugifiedPattern, filenameReplacement);
|
||||
} else {
|
||||
const start = source.indexOf("function generateFilename(");
|
||||
const end = source.indexOf("\nfunction downloadWithAnchor(", start);
|
||||
if (start !== -1 && end !== -1) {
|
||||
source = source.slice(0, start) + filenameReplacement + source.slice(end);
|
||||
} else {
|
||||
warn("SSR generateFilename marker not found, skipped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source = source
|
||||
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
|
||||
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(3.0) }),")
|
||||
.replace(/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(3.0) }),")
|
||||
.replace(/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/, "sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},");
|
||||
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
|
||||
"sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},\n\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },\n\t\t\t\titem: { rowGap: metrics.gapY(.2) },",
|
||||
);
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
|
||||
"sidebarColumn: {\n\t\t\t\t\tzIndex: 1,\n\t\t\t\t\tbackgroundColor: primaryTint,\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,\n\t\t\t\t\trowGap: metrics.gapY(3.0)\n\t\t\t\t},",
|
||||
);
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
|
||||
"mainContent: {\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical\n\t\t\t\t},",
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
function patchAppManifest(file, oldBase, newBase) {
|
||||
if (!fs.existsSync(file)) return false;
|
||||
let source = read(file);
|
||||
const next = source.replace(new RegExp(`/assets/${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `/assets/${newBase}`);
|
||||
if (next !== source) {
|
||||
write(file, next);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function cloneStaticManifestEntry(source, oldUrl, newUrl, oldBase, newBase, newFile) {
|
||||
const marker = `\t"${oldUrl}": {`;
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
warn(`static manifest entry not found for ${oldUrl}, skipped app shell cache bust`);
|
||||
return source;
|
||||
}
|
||||
|
||||
if (source.includes(`\t"${newUrl}": {`)) {
|
||||
return patchStaticManifestEntry(source, newUrl, newFile);
|
||||
}
|
||||
|
||||
const close = source.indexOf("\n\t}", start);
|
||||
if (close === -1 || source[close + 3] !== ",") {
|
||||
warn(`static manifest entry close marker not found for ${oldUrl}, skipped app shell cache bust`);
|
||||
return source;
|
||||
}
|
||||
|
||||
let entry = source.slice(start, close + 3);
|
||||
const buffer = fs.readFileSync(newFile);
|
||||
entry = entry
|
||||
.replace(oldUrl, newUrl)
|
||||
.replace(`../public/assets/${oldBase}`, `../public/assets/${newBase}`)
|
||||
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
|
||||
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
|
||||
.replace(/"size": \d+/, `"size": ${buffer.length}`);
|
||||
|
||||
return source.slice(0, close + 4) + "\n" + entry + "," + source.slice(close + 4);
|
||||
}
|
||||
|
||||
function patchAppShellEntry(assetFiles) {
|
||||
const indexFile = assetFiles.find((file) => /^index-[A-Za-z0-9_-]+\.js$/.test(path.basename(file)));
|
||||
if (!indexFile) {
|
||||
warn("index app shell bundle not found, skipped app shell cache bust");
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldBase = path.basename(indexFile);
|
||||
const newBase = oldBase.replace(/\.js$/, `-${appShellSuffix}.js`);
|
||||
const newFile = path.join(path.dirname(indexFile), newBase);
|
||||
if (!fs.existsSync(newFile)) {
|
||||
fs.copyFileSync(indexFile, newFile);
|
||||
}
|
||||
return { oldBase, newBase, newFile };
|
||||
}
|
||||
|
||||
function patchAppShellImporters(assetFiles, oldBase, newBase) {
|
||||
const touched = [];
|
||||
for (const file of assetFiles) {
|
||||
if (path.basename(file) === oldBase) continue;
|
||||
let source = read(file);
|
||||
let next = source
|
||||
.replace(new RegExp(`\\./${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `./${newBase}`)
|
||||
.replace(new RegExp(`/assets/${escapeRegex(oldBase)}(?:\\?v=rr-app-shell-[A-Za-z0-9-]+)?`, "g"), `/assets/${newBase}`);
|
||||
if (next !== source) {
|
||||
write(file, next);
|
||||
touched.push(file);
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
function patchPublicPdf(source) {
|
||||
if (!source.includes("rr-browser-buffer-polyfill")) {
|
||||
const importPrelude = source.match(/^(?:import[^;]+;)+/);
|
||||
const insertAt = importPrelude ? importPrelude[0].length : source.indexOf(";") + 1;
|
||||
if (insertAt > 0 && source.startsWith("import")) {
|
||||
source = source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
|
||||
} else {
|
||||
warn("PDF bundle import prelude not found, Buffer shim skipped");
|
||||
}
|
||||
}
|
||||
|
||||
source = source
|
||||
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/, "$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
|
||||
.replace(/[oc]\.gapY\(3\.5\)/g, (m) => `${m[0]}.gapY(3.0)`)
|
||||
.replace(/[oc]\.gapY\(2\.6\)/g, (m) => `${m[0]}.gapY(3.0)`)
|
||||
.replace(/[oc]\.gapY\(2\.2\)/g, (m) => `${m[0]}.gapY(3.0)`)
|
||||
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
|
||||
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})")
|
||||
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}")
|
||||
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}");
|
||||
|
||||
source = replaceOnce(source, "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}", "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}");
|
||||
source = replaceOnce(source, "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}", "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}");
|
||||
return source;
|
||||
}
|
||||
|
||||
const assetFiles = listJsFiles(assetsDir);
|
||||
const filenameFiles = assetFiles
|
||||
.filter((file) => {
|
||||
if (!/^file-[A-Za-z0-9_-]+\.js$/.test(path.basename(file))) return false;
|
||||
const source = read(file);
|
||||
return source.includes("URL.createObjectURL") && source.includes(".download") && source.includes("revokeObjectURL");
|
||||
})
|
||||
.sort((a, b) => fs.statSync(a).size - fs.statSync(b).size);
|
||||
|
||||
const patchedFilenameFiles = [];
|
||||
for (const file of filenameFiles) {
|
||||
if (patchFilenameBundle(file)) patchedFilenameFiles.push(file);
|
||||
}
|
||||
if (patchedFilenameFiles.length === 0) warn("no filename bundle patched");
|
||||
|
||||
let ssrFile = "";
|
||||
if (explicitSsrFile && fs.existsSync(explicitSsrFile) && read(explicitSsrFile).includes("function generateFilename(")) {
|
||||
ssrFile = explicitSsrFile;
|
||||
} else if (ssrDir && fs.existsSync(ssrDir)) {
|
||||
ssrFile = fs.readdirSync(ssrDir)
|
||||
.filter((name) => name.endsWith(".mjs"))
|
||||
.map((name) => path.join(ssrDir, name))
|
||||
.find((file) => read(file).includes("function generateFilename(")) || "";
|
||||
}
|
||||
if (ssrFile) {
|
||||
write(ssrFile, patchSsr(read(ssrFile)));
|
||||
} else {
|
||||
warn("SSR bundle with generateFilename not found");
|
||||
}
|
||||
|
||||
const appShell = patchAppShellEntry(assetFiles);
|
||||
const appShellTouchedImporters = appShell ? patchAppShellImporters(assetFiles, appShell.oldBase, appShell.newBase) : [];
|
||||
if (appShell) {
|
||||
const appManifestFiles = [
|
||||
...listFilesRecursive(path.join(outputDir, "server"), (file) => path.basename(file).startsWith("_tanstack-start-manifest") && file.endsWith(".mjs")),
|
||||
...listFilesRecursive(path.join(appDir, "apps/server/dist"), (file) => path.basename(file).startsWith("_tanstack-start-manifest") && file.endsWith(".mjs")),
|
||||
];
|
||||
for (const file of appManifestFiles) patchAppManifest(file, appShell.oldBase, appShell.newBase);
|
||||
}
|
||||
|
||||
const pdfFile = assetFiles
|
||||
.filter((file) => path.basename(file).startsWith("pdf-document-"))
|
||||
.sort((a, b) => fs.statSync(b).size - fs.statSync(a).size)[0] || "";
|
||||
if (pdfFile) {
|
||||
write(pdfFile, patchPublicPdf(read(pdfFile)));
|
||||
} else {
|
||||
warn("public PDF bundle not found");
|
||||
}
|
||||
|
||||
const filenameBases = patchedFilenameFiles.map((file) => path.basename(file));
|
||||
const pdfBase = pdfFile ? path.basename(pdfFile) : "";
|
||||
const touchedImporters = [];
|
||||
for (const file of assetFiles) {
|
||||
let source = read(file);
|
||||
let next = source;
|
||||
for (const base of filenameBases) {
|
||||
next = next.replace(new RegExp(`\\./${escapeRegex(base)}(?:\\?v=rr-filename-[A-Za-z0-9-]+)?`, "g"), `./${base}?v=${filenameCacheBust}`);
|
||||
}
|
||||
if (pdfBase) {
|
||||
next = next.replace(new RegExp(`\\./${escapeRegex(pdfBase)}(?:\\?v=rr-[^"'\\\`]+)?`, "g"), `./${pdfBase}?v=${pdfCacheBust}`);
|
||||
}
|
||||
if (next !== source) {
|
||||
write(file, next);
|
||||
touchedImporters.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(serverIndexFile)) {
|
||||
let serverIndex = read(serverIndexFile);
|
||||
for (const file of [...patchedFilenameFiles, pdfFile, ...touchedImporters, ...appShellTouchedImporters].filter(Boolean)) {
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, `/assets/${path.basename(file)}`, file);
|
||||
}
|
||||
if (appShell) {
|
||||
serverIndex = cloneStaticManifestEntry(
|
||||
serverIndex,
|
||||
`/assets/${appShell.oldBase}`,
|
||||
`/assets/${appShell.newBase}`,
|
||||
appShell.oldBase,
|
||||
appShell.newBase,
|
||||
appShell.newFile,
|
||||
);
|
||||
}
|
||||
const serviceWorkerFile = path.join(path.dirname(assetsDir), "sw.js");
|
||||
if (fs.existsSync(serviceWorkerFile)) {
|
||||
write(serviceWorkerFile, serviceWorkerCleanup);
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, "/sw.js", serviceWorkerFile);
|
||||
}
|
||||
write(serverIndexFile, serverIndex);
|
||||
}
|
||||
|
||||
for (const file of [...patchedFilenameFiles, pdfFile, ssrFile, serverIndexFile].filter(Boolean)) {
|
||||
try {
|
||||
new Function(read(file));
|
||||
} catch {
|
||||
// ESM/import bundles are validated by Node at application startup; keep this
|
||||
// best-effort so a harmless syntax-check limitation never blocks boot.
|
||||
}
|
||||
}
|
||||
NODE
|
||||
1250
packages/reactive-resume-personal-qnap-nas/seed/resume-data.json
Normal file
1326
packages/reactive-resume-personal-qnap-nas/seed/seed.sql
Normal file
|
After Width: | Height: | Size: 6.4 MiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 97 KiB |
121
scripts/patch-reactive-resume-filename.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
|
||||
|
||||
docker exec -u root -i "$CONTAINER" sh <<'SH'
|
||||
set -eu
|
||||
|
||||
PUBLIC_FILE="/app/apps/web/.output/public/assets/file-D5WsIgJH.js"
|
||||
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
|
||||
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
|
||||
CACHE_BUST="rr-filename-title-20260519"
|
||||
|
||||
cp "$PUBLIC_FILE" "$PUBLIC_FILE.bak-filename" 2>/dev/null || true
|
||||
cp "$SSR_FILE" "$SSR_FILE.bak-filename" 2>/dev/null || true
|
||||
cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-filename" 2>/dev/null || true
|
||||
|
||||
node - <<'NODE'
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const publicFile = '/app/apps/web/.output/public/assets/file-D5WsIgJH.js';
|
||||
const ssrFile = '/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs';
|
||||
const serverIndexFile = '/app/apps/web/.output/server/index.mjs';
|
||||
const cacheBust = 'rr-filename-title-20260519';
|
||||
|
||||
function makeEtag(buffer) {
|
||||
const digest = crypto.createHash('sha1').update(buffer).digest('base64').replace(/=+$/g, '');
|
||||
return `"${buffer.length.toString(16)}-${digest}"`;
|
||||
}
|
||||
|
||||
function patchStaticManifestEntry(source, urlPath, filePath) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const startMarker = `"${urlPath}": {`;
|
||||
const start = source.indexOf(startMarker);
|
||||
if (start === -1) throw new Error(`Static manifest entry not found for ${urlPath}`);
|
||||
|
||||
const commaEnd = source.indexOf('\n\t},', start);
|
||||
const objectEnd = source.indexOf('\n\t}', start);
|
||||
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
|
||||
if (end === -1) throw new Error(`Static manifest entry end not found for ${urlPath}`);
|
||||
|
||||
let entry = source.slice(start, end);
|
||||
entry = entry
|
||||
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
|
||||
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
|
||||
.replace(/"size": \d+/, `"size": ${buffer.length}`);
|
||||
|
||||
return source.slice(0, start) + entry + source.slice(end);
|
||||
}
|
||||
|
||||
function patchPublicImporters() {
|
||||
const assetsDir = '/app/apps/web/.output/public/assets';
|
||||
const files = fs
|
||||
.readdirSync(assetsDir)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.map((name) => `${assetsDir}/${name}`)
|
||||
.filter((file) => fs.readFileSync(file, 'utf8').includes('file-D5WsIgJH.js'));
|
||||
|
||||
for (const file of files) {
|
||||
let source = fs.readFileSync(file, 'utf8');
|
||||
source = source.replace(/\.\/file-D5WsIgJH\.js(?:\?v=rr-filename-[A-Za-z0-9-]+)?/g, `./file-D5WsIgJH.js?v=${cacheBust}`);
|
||||
fs.writeFileSync(file, source);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const publicFilenameFunction = 'function t(e,t){let n=(e||"resume").toString().trim()||"resume";return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",t&&n.toLowerCase().endsWith("."+t.toLowerCase())?n:`${n}${t?`.${t}`:""}`}';
|
||||
|
||||
let publicJs = fs.readFileSync(publicFile, 'utf8');
|
||||
const publicReplacement = publicFilenameFunction;
|
||||
if (!publicJs.includes(publicReplacement)) {
|
||||
const start = publicJs.indexOf('function t(');
|
||||
const end = publicJs.indexOf('function n(', start);
|
||||
if (start === -1 || end === -1) throw new Error('Public generateFilename marker not found');
|
||||
publicJs = publicJs.slice(0, start) + publicReplacement + publicJs.slice(end);
|
||||
}
|
||||
fs.writeFileSync(publicFile, publicJs);
|
||||
|
||||
let ssr = fs.readFileSync(ssrFile, 'utf8');
|
||||
ssr = ssr.replace(
|
||||
/\n\t\tname: "",\n\t\tdata: \{/,
|
||||
'\n\t\tname: resume.name,\n\t\tdata: {',
|
||||
);
|
||||
const ssrReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix || "resume").toString().trim() || "resume";\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn extension && filename.toLowerCase().endsWith(\`.\${extension.toLowerCase()}\`) ? filename : \`\${filename}\${extension ? \`.\${extension}\` : ""}\`;\n}`;
|
||||
if (!ssr.includes(ssrReplacement)) {
|
||||
const start = ssr.indexOf('function generateFilename(');
|
||||
const end = ssr.indexOf('\nfunction downloadWithAnchor(', start);
|
||||
if (start === -1 || end === -1) throw new Error('SSR generateFilename marker not found');
|
||||
ssr = ssr.slice(0, start) + ssrReplacement + ssr.slice(end);
|
||||
}
|
||||
fs.writeFileSync(ssrFile, ssr);
|
||||
|
||||
const importers = patchPublicImporters();
|
||||
let serverIndex = fs.readFileSync(serverIndexFile, 'utf8');
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, '/assets/file-D5WsIgJH.js', publicFile);
|
||||
for (const file of importers) {
|
||||
const urlPath = `/assets/${file.split('/').pop()}`;
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, urlPath, file);
|
||||
}
|
||||
fs.writeFileSync(serverIndexFile, serverIndex);
|
||||
NODE
|
||||
|
||||
node --check "$PUBLIC_FILE" >/dev/null
|
||||
node --check "$SSR_FILE" >/dev/null
|
||||
node --check "$SERVER_INDEX_FILE" >/dev/null
|
||||
SH
|
||||
|
||||
# Nitro loads the static asset manifest into memory at process startup. Restart
|
||||
# after patching so updated content-length/etag values are used immediately.
|
||||
docker restart "$CONTAINER" >/dev/null
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER")"
|
||||
[ "$health" = "healthy" ] && exit 0
|
||||
sleep 2
|
||||
done
|
||||
|
||||
docker logs --tail 80 "$CONTAINER" >&2
|
||||
exit 1
|
||||
236
scripts/patch-reactive-resume-glalie-layout.sh
Executable file
@@ -0,0 +1,236 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
|
||||
|
||||
docker exec -u root -i "$CONTAINER" sh <<'SH'
|
||||
set -eu
|
||||
|
||||
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
|
||||
PUBLIC_FILE="/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js"
|
||||
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
|
||||
CACHE_BUST="rr-glalie-layout-20260519"
|
||||
|
||||
test -f "$SSR_FILE.bak-glalie-layout" || cp "$SSR_FILE" "$SSR_FILE.bak-glalie-layout" 2>/dev/null || true
|
||||
test -f "$PUBLIC_FILE.bak-glalie-layout" || cp "$PUBLIC_FILE" "$PUBLIC_FILE.bak-glalie-layout" 2>/dev/null || true
|
||||
test -f "$SERVER_INDEX_FILE.bak-glalie-layout" || cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-glalie-layout" 2>/dev/null || true
|
||||
|
||||
node - <<'NODE'
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const ssrFile = "/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs";
|
||||
const publicFile = "/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js";
|
||||
const serverIndexFile = "/app/apps/web/.output/server/index.mjs";
|
||||
const cacheBust = "rr-glalie-layout-20260519";
|
||||
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
|
||||
|
||||
function replaceOnce(source, from, to, label) {
|
||||
if (source.includes(to)) return source;
|
||||
if (!source.includes(from)) throw new Error(`Patch marker not found: ${label}`);
|
||||
return source.replace(from, to);
|
||||
}
|
||||
|
||||
function replaceRegexOnce(source, regex, to, label) {
|
||||
if (source.includes(to)) return source;
|
||||
const next = source.replace(regex, to);
|
||||
if (next === source) throw new Error(`Patch marker not found: ${label}`);
|
||||
return next;
|
||||
}
|
||||
|
||||
function patchSsr(source) {
|
||||
source = source
|
||||
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
|
||||
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)");
|
||||
|
||||
source = source
|
||||
.replace(
|
||||
/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g,
|
||||
"style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(2.2) }),",
|
||||
)
|
||||
.replace(
|
||||
/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g,
|
||||
"style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(2.2) }),",
|
||||
);
|
||||
source = source.replace(
|
||||
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/,
|
||||
`sectionHeading: {
|
||||
\t\t\t\t\tborderBottomWidth: 1,
|
||||
\t\t\t\t\tborderBottomColor: primary,
|
||||
\t\t\t\t\tpaddingBottom: 1.3
|
||||
\t\t\t\t},`,
|
||||
);
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
|
||||
`sectionHeading: {
|
||||
\t\t\t\t\tborderBottomWidth: 1,
|
||||
\t\t\t\t\tborderBottomColor: primary,
|
||||
\t\t\t\t\tpaddingBottom: 1.3
|
||||
\t\t\t\t},
|
||||
\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },
|
||||
\t\t\t\titem: { rowGap: metrics.gapY(.2) },`,
|
||||
"SSR Glalie section item spacing",
|
||||
);
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
|
||||
`sidebarColumn: {
|
||||
\t\t\t\t\tzIndex: 1,
|
||||
\t\t\t\t\tbackgroundColor: primaryTint,
|
||||
\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,
|
||||
\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,
|
||||
\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,
|
||||
\t\t\t\t\trowGap: metrics.gapY(3.0)
|
||||
\t\t\t\t},`,
|
||||
"SSR Glalie sidebar bottom padding",
|
||||
);
|
||||
source = replaceRegexOnce(
|
||||
source,
|
||||
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
|
||||
`mainContent: {
|
||||
\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,
|
||||
\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,
|
||||
\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical
|
||||
\t\t\t\t},`,
|
||||
"SSR Glalie main bottom padding",
|
||||
);
|
||||
return source
|
||||
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
|
||||
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
|
||||
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)");
|
||||
}
|
||||
|
||||
function ensureBrowserBufferPolyfill(source) {
|
||||
if (source.includes("rr-browser-buffer-polyfill")) return source;
|
||||
|
||||
const insertAt = source.indexOf(";") + 1;
|
||||
if (insertAt <= 0 || !source.startsWith("import")) {
|
||||
throw new Error("public PDF bundle import prelude not found");
|
||||
}
|
||||
|
||||
return source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
|
||||
}
|
||||
|
||||
function patchPublic(source) {
|
||||
source = ensureBrowserBufferPolyfill(source);
|
||||
|
||||
source = source
|
||||
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/,
|
||||
"$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
|
||||
.replace(/o\.gapY\(3\.5\)/g, "o.gapY(3.0)")
|
||||
.replace(/c\.gapY\(3\.5\)/g, "c.gapY(3.0)")
|
||||
.replace(/o\.gapY\(2\.6\)/g, "o.gapY(3.0)")
|
||||
.replace(/c\.gapY\(2\.6\)/g, "c.gapY(3.0)")
|
||||
.replace(/o\.gapY\(2\.2\)/g, "o.gapY(3.0)")
|
||||
.replace(/c\.gapY\(2\.2\)/g, "c.gapY(3.0)");
|
||||
|
||||
source = source
|
||||
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
|
||||
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})");
|
||||
source = source.replace(
|
||||
/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/,
|
||||
"sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}",
|
||||
);
|
||||
source = source.replace(
|
||||
/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/,
|
||||
"sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
"sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}",
|
||||
"sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}",
|
||||
"public Glalie sidebar bottom padding",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
"mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}",
|
||||
"mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}",
|
||||
"public Glalie main bottom padding",
|
||||
);
|
||||
return source
|
||||
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/,
|
||||
"$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
|
||||
.replace(/o\.gapY\(3\.5\)/g, "o.gapY(3.0)")
|
||||
.replace(/c\.gapY\(3\.5\)/g, "c.gapY(3.0)")
|
||||
.replace(/o\.gapY\(2\.6\)/g, "o.gapY(3.0)")
|
||||
.replace(/c\.gapY\(2\.6\)/g, "c.gapY(3.0)")
|
||||
.replace(/o\.gapY\(2\.2\)/g, "o.gapY(3.0)")
|
||||
.replace(/c\.gapY\(2\.2\)/g, "c.gapY(3.0)");
|
||||
}
|
||||
|
||||
function patchImporters() {
|
||||
const assetsDir = "/app/apps/web/.output/public/assets";
|
||||
const files = fs
|
||||
.readdirSync(assetsDir)
|
||||
.filter((name) => name.endsWith(".js"))
|
||||
.map((name) => `${assetsDir}/${name}`)
|
||||
.filter((file) => fs.readFileSync(file, "utf8").includes("pdf-document-BplbXx-0.js"));
|
||||
|
||||
for (const file of files) {
|
||||
let source = fs.readFileSync(file, "utf8");
|
||||
source = source.replace(/\.\/pdf-document-BplbXx-0\.js(?:\?v=rr-[^"'`]+)?/g, `./pdf-document-BplbXx-0.js?v=${cacheBust}`);
|
||||
fs.writeFileSync(file, source);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function makeEtag(buffer) {
|
||||
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
|
||||
return `"${buffer.length.toString(16)}-${digest}"`;
|
||||
}
|
||||
|
||||
function patchStaticManifestEntry(source, urlPath, filePath) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const startMarker = `"${urlPath}": {`;
|
||||
const start = source.indexOf(startMarker);
|
||||
if (start === -1) throw new Error(`Static manifest entry not found for ${urlPath}`);
|
||||
|
||||
const commaEnd = source.indexOf("\n\t},", start);
|
||||
const objectEnd = source.indexOf("\n\t}", start);
|
||||
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
|
||||
if (end === -1) throw new Error(`Static manifest entry end not found for ${urlPath}`);
|
||||
|
||||
let entry = source.slice(start, end);
|
||||
entry = entry
|
||||
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
|
||||
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
|
||||
.replace(/"size": \d+/, `"size": ${buffer.length}`);
|
||||
|
||||
return source.slice(0, start) + entry + source.slice(end);
|
||||
}
|
||||
|
||||
fs.writeFileSync(ssrFile, patchSsr(fs.readFileSync(ssrFile, "utf8")));
|
||||
fs.writeFileSync(publicFile, patchPublic(fs.readFileSync(publicFile, "utf8")));
|
||||
|
||||
const importers = patchImporters();
|
||||
let serverIndex = fs.readFileSync(serverIndexFile, "utf8");
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, "/assets/pdf-document-BplbXx-0.js", publicFile);
|
||||
for (const file of importers) {
|
||||
const urlPath = `/assets/${file.split("/").pop()}`;
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, urlPath, file);
|
||||
}
|
||||
fs.writeFileSync(serverIndexFile, serverIndex);
|
||||
NODE
|
||||
|
||||
node --check "$SSR_FILE" >/dev/null
|
||||
node --check "$PUBLIC_FILE" >/dev/null
|
||||
node --check "$SERVER_INDEX_FILE" >/dev/null
|
||||
SH
|
||||
|
||||
# Nitro loads the static asset manifest into memory at process startup. Restart
|
||||
# after patching so updated content-length/etag values are used immediately.
|
||||
docker restart "$CONTAINER" >/dev/null
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER")"
|
||||
[ "$health" = "healthy" ] && exit 0
|
||||
sleep 2
|
||||
done
|
||||
|
||||
docker logs --tail 80 "$CONTAINER" >&2
|
||||
exit 1
|
||||
161
scripts/patch-reactive-resume-service-worker-cache.sh
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
|
||||
|
||||
docker exec -u root -i "$CONTAINER" sh <<'SH'
|
||||
set -eu
|
||||
|
||||
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
|
||||
SW_FILE="/app/apps/web/.output/public/sw.js"
|
||||
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
|
||||
SSR_RENDERER_FILE="/app/apps/web/.output/server/_chunks/ssr-renderer.mjs"
|
||||
|
||||
test -f "$SSR_FILE.bak-sw-cache" || cp "$SSR_FILE" "$SSR_FILE.bak-sw-cache" 2>/dev/null || true
|
||||
test -f "$SW_FILE.bak-sw-cache" || cp "$SW_FILE" "$SW_FILE.bak-sw-cache" 2>/dev/null || true
|
||||
test -f "$SERVER_INDEX_FILE.bak-sw-cache" || cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-sw-cache" 2>/dev/null || true
|
||||
test -f "$SSR_RENDERER_FILE.bak-sw-cache" || cp "$SSR_RENDERER_FILE" "$SSR_RENDERER_FILE.bak-sw-cache" 2>/dev/null || true
|
||||
|
||||
node - <<'NODE'
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const ssrFile = "/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs";
|
||||
const swFile = "/app/apps/web/.output/public/sw.js";
|
||||
const serverIndexFile = "/app/apps/web/.output/server/index.mjs";
|
||||
const ssrRendererFile = "/app/apps/web/.output/server/_chunks/ssr-renderer.mjs";
|
||||
|
||||
const registrationScript = `
|
||||
\t(() => {
|
||||
\t\tif (!("serviceWorker" in navigator)) return;
|
||||
|
||||
\t\twindow.addEventListener("load", () => {
|
||||
\t\t\tconst clearReactiveResumeCaches = async () => {
|
||||
\t\t\t\tif ("caches" in window) {
|
||||
\t\t\t\t\tconst keys = await caches.keys();
|
||||
\t\t\t\t\tawait Promise.all(keys.map((key) => caches.delete(key)));
|
||||
\t\t\t\t}
|
||||
|
||||
\t\t\t\tif (navigator.serviceWorker.getRegistrations) {
|
||||
\t\t\t\t\tconst registrations = await navigator.serviceWorker.getRegistrations();
|
||||
\t\t\t\t\tawait Promise.all(registrations.map((registration) => registration.unregister()));
|
||||
\t\t\t\t}
|
||||
\t\t\t};
|
||||
|
||||
\t\t\tclearReactiveResumeCaches().catch(console.error);
|
||||
\t\t});
|
||||
\t})();
|
||||
`;
|
||||
|
||||
let ssr = fs.readFileSync(ssrFile, "utf8");
|
||||
const start = "var pwaServiceWorkerRegistrationScript = `";
|
||||
const end = "`;\nvar src_default =";
|
||||
const startIndex = ssr.indexOf(start);
|
||||
if (startIndex === -1) {
|
||||
throw new Error("Service worker registration script start marker not found");
|
||||
}
|
||||
const endIndex = ssr.indexOf(end, startIndex + start.length);
|
||||
if (endIndex === -1) {
|
||||
throw new Error("Service worker registration script end marker not found");
|
||||
}
|
||||
|
||||
ssr =
|
||||
ssr.slice(0, startIndex) +
|
||||
start +
|
||||
registrationScript +
|
||||
ssr.slice(endIndex);
|
||||
fs.writeFileSync(ssrFile, ssr);
|
||||
|
||||
const sw = `self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
await self.registration.unregister();
|
||||
await self.clients.claim();
|
||||
|
||||
const clients = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
|
||||
for (const client of clients) {
|
||||
client.postMessage({ type: "RR_SW_CACHE_CLEARED" });
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", () => {});
|
||||
`;
|
||||
fs.writeFileSync(swFile, sw);
|
||||
|
||||
function makeEtag(buffer) {
|
||||
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
|
||||
return `"${buffer.length.toString(16)}-${digest}"`;
|
||||
}
|
||||
|
||||
function patchStaticManifestEntry(source, urlPath, filePath) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const startMarker = `"${urlPath}": {`;
|
||||
const start = source.indexOf(startMarker);
|
||||
if (start === -1) {
|
||||
throw new Error(`Static manifest entry not found for ${urlPath}`);
|
||||
}
|
||||
|
||||
const end = source.indexOf("\n\t},", start);
|
||||
if (end === -1) {
|
||||
throw new Error(`Static manifest entry end not found for ${urlPath}`);
|
||||
}
|
||||
|
||||
let entry = source.slice(start, end);
|
||||
entry = entry
|
||||
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
|
||||
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
|
||||
.replace(/"size": \d+/, `"size": ${buffer.length}`);
|
||||
|
||||
return source.slice(0, start) + entry + source.slice(end);
|
||||
}
|
||||
|
||||
let serverIndex = fs.readFileSync(serverIndexFile, "utf8");
|
||||
serverIndex = patchStaticManifestEntry(serverIndex, "/sw.js", swFile);
|
||||
fs.writeFileSync(serverIndexFile, serverIndex);
|
||||
|
||||
let ssrRenderer = fs.readFileSync(ssrRendererFile, "utf8");
|
||||
const ssrRendererOriginal = `function ssrRenderer({ req }) {
|
||||
\treturn fetchViteEnv("ssr", req);
|
||||
}`;
|
||||
const ssrRendererPatched = `async function ssrRenderer(event) {
|
||||
\tconst response = await fetchViteEnv("ssr", event.req);
|
||||
\tconst headers = new Headers(response.headers);
|
||||
\tconst accept = event.req.headers.get("accept") || "";
|
||||
|
||||
\tif (accept.includes("text/html")) {
|
||||
\t\theaders.set("Cache-Control", "no-store, max-age=0");
|
||||
\t\theaders.set("Pragma", "no-cache");
|
||||
\t\theaders.set("Expires", "0");
|
||||
\t}
|
||||
|
||||
\treturn new Response(response.body, {
|
||||
\t\tstatus: response.status,
|
||||
\t\tstatusText: response.statusText,
|
||||
\t\theaders,
|
||||
\t});
|
||||
}`;
|
||||
if (!ssrRenderer.includes(ssrRendererPatched)) {
|
||||
if (!ssrRenderer.includes(ssrRendererOriginal)) {
|
||||
throw new Error("SSR renderer marker not found");
|
||||
}
|
||||
|
||||
ssrRenderer = ssrRenderer.replace(ssrRendererOriginal, ssrRendererPatched);
|
||||
fs.writeFileSync(ssrRendererFile, ssrRenderer);
|
||||
}
|
||||
NODE
|
||||
|
||||
node --check "$SSR_FILE" >/dev/null
|
||||
node --check "$SW_FILE" >/dev/null
|
||||
node --check "$SERVER_INDEX_FILE" >/dev/null
|
||||
node --check "$SSR_RENDERER_FILE" >/dev/null
|
||||
SH
|
||||
338
scripts/test-personal-install-packages.sh
Executable file
@@ -0,0 +1,338 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
DIRECT_COMPOSE="$ROOT_DIR/packages/reactive-resume-personal-direct/compose.yml"
|
||||
DIRECT_ENV="$ROOT_DIR/packages/reactive-resume-personal-direct/.env"
|
||||
QNAP_COMPOSE="$ROOT_DIR/packages/reactive-resume-personal-qnap-nas/compose-Nas.yml"
|
||||
QNAP_PATCH_DIR="$ROOT_DIR/packages/reactive-resume-personal-qnap-nas/patches"
|
||||
DIRECT_PATCH_DIR="$ROOT_DIR/packages/reactive-resume-personal-direct/patches"
|
||||
QNAP_ZIP="$ROOT_DIR/dist/reactive-resume-personal-qnap-nas-20260520.zip"
|
||||
DIRECT_ZIP="$ROOT_DIR/dist/reactive-resume-personal-direct-20260520.zip"
|
||||
IMAGE_REPO="amruthpillai/reactive-resume"
|
||||
IMAGE_INDEX="$IMAGE_REPO@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa"
|
||||
PROJECT="reactive-resume-personal"
|
||||
|
||||
log() {
|
||||
printf '\n[TEST] %s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '\n[FAIL] %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup_direct() {
|
||||
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" down -v >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup_tmp() {
|
||||
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_all() {
|
||||
cleanup_direct
|
||||
cleanup_tmp
|
||||
}
|
||||
|
||||
trap cleanup_all HUP INT TERM EXIT
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
log "检查补丁脚本语法"
|
||||
sh -n "$QNAP_PATCH_DIR/reactive-resume-runtime-patch.sh"
|
||||
sh -n "$QNAP_PATCH_DIR/reactive-resume-entrypoint.sh"
|
||||
sh -n "$DIRECT_PATCH_DIR/reactive-resume-runtime-patch.sh"
|
||||
sh -n "$DIRECT_PATCH_DIR/reactive-resume-entrypoint.sh"
|
||||
|
||||
log "检查 Compose 配置可解析"
|
||||
docker compose -f "$QNAP_COMPOSE" config >/tmp/reactive-resume-qnap-compose-test.yml
|
||||
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" config >/tmp/reactive-resume-direct-compose-test.yml
|
||||
grep -q 'reactive-resume-entrypoint.sh' /tmp/reactive-resume-qnap-compose-test.yml
|
||||
grep -q 'reactive-resume-entrypoint.sh' /tmp/reactive-resume-direct-compose-test.yml
|
||||
|
||||
log "检查 zip 安装包内容"
|
||||
unzip -t "$QNAP_ZIP" >/dev/null
|
||||
unzip -t "$DIRECT_ZIP" >/dev/null
|
||||
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/compose-Nas.yml'
|
||||
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/patches/reactive-resume-entrypoint.sh'
|
||||
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/patches/reactive-resume-runtime-patch.sh'
|
||||
unzip -l "$DIRECT_ZIP" | grep -q 'reactive-resume-personal-direct/compose.yml'
|
||||
unzip -l "$DIRECT_ZIP" | grep -q 'reactive-resume-personal-direct/patches/reactive-resume-entrypoint.sh'
|
||||
|
||||
if unzip -p "$QNAP_ZIP" 'reactive_resume/*' 2>/dev/null | grep -E 'isiseg|10004|Reactive_Resume_Personal|/share/Container/Reactive_Resume_Personal' >/dev/null; then
|
||||
fail "QNAP zip 中仍有旧域名、旧端口或旧路径"
|
||||
fi
|
||||
if unzip -p "$QNAP_ZIP" 'reactive_resume/*' 2>/dev/null | grep -E 'amruthpillai/reactive-resume:latest|snowdreamtech/frpc:latest' >/dev/null; then
|
||||
fail "QNAP zip 中仍有 latest 镜像"
|
||||
fi
|
||||
if unzip -p "$DIRECT_ZIP" 'reactive-resume-personal-direct/*' 2>/dev/null | grep -E 'amruthpillai/reactive-resume:latest|snowdreamtech/frpc:latest' >/dev/null; then
|
||||
fail "direct zip 中仍有 latest 镜像"
|
||||
fi
|
||||
|
||||
log "真实启动 direct 包并检查健康状态"
|
||||
cleanup_direct
|
||||
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" up -d postgres reactive-resume seed
|
||||
|
||||
attempt=0
|
||||
until curl -fsS "http://127.0.0.1:3004/api/health" >/tmp/reactive-resume-health.json 2>/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 60 ]; then
|
||||
docker logs "$PROJECT-reactive-resume-1" --tail 200 >&2 || true
|
||||
fail "direct 包启动后 /api/health 未在 60 秒内就绪"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker wait "$PROJECT-seed-1" >/tmp/reactive-resume-seed-exit
|
||||
seed_status="$(docker inspect "$PROJECT-seed-1" --format '{{.State.ExitCode}}' 2>/dev/null || printf 'missing')"
|
||||
[ "$seed_status" = "0" ] || fail "seed 容器退出码不是 0:$seed_status"
|
||||
|
||||
attempt=0
|
||||
until curl -fsS -I "http://127.0.0.1:3004/audience/resume" >/tmp/reactive-resume-audience.headers 2>/dev/null \
|
||||
&& grep -q '200 OK' /tmp/reactive-resume-audience.headers; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 30 ]; then
|
||||
docker logs "$PROJECT-reactive-resume-1" --tail 200 >&2 || true
|
||||
fail "seed 完成后 /audience/resume 未在 30 秒内返回 200"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker logs "$PROJECT-reactive-resume-1" --tail 200 >/tmp/reactive-resume-direct.log 2>&1 || true
|
||||
if grep -E 'Cannot find module|Buffer is not defined|Unexpected end of input' /tmp/reactive-resume-direct.log >/dev/null; then
|
||||
cat /tmp/reactive-resume-direct.log >&2
|
||||
fail "direct 包日志仍包含已知启动或前端错误"
|
||||
fi
|
||||
if docker exec "$PROJECT-reactive-resume-1" sh -lc 'APP_DIR=$(cat /tmp/reactive-resume-app-dir); grep -R -E "index-[A-Za-z0-9_-]+\\.js\\?v=rr-filename-title" "$APP_DIR/.output/public/assets" >/dev/null 2>&1'; then
|
||||
fail "direct 包错误地给 index 主入口追加了 rr-filename-title 缓存标记"
|
||||
fi
|
||||
docker exec "$PROJECT-reactive-resume-1" sh -lc 'APP_DIR=$(cat /tmp/reactive-resume-app-dir); grep -R "String(t).trim().replace" "$APP_DIR/.output/public/assets"/file-*.js >/dev/null 2>&1' \
|
||||
|| fail "direct 包未在 file-*.js 下载工具中应用文件名补丁"
|
||||
curl -fsS "http://127.0.0.1:3004/" >/tmp/reactive-resume-home.html \
|
||||
|| fail "direct 包首页无法访问"
|
||||
grep -E '/assets/index-[A-Za-z0-9_-]+-rr[0-9a-z]+\.js' /tmp/reactive-resume-home.html >/dev/null \
|
||||
|| fail "direct 包首页未改用防缓存的 index 主入口文件名"
|
||||
curl -fsS "http://127.0.0.1:3004/sw.js" >/tmp/reactive-resume-sw.js \
|
||||
|| fail "direct 包 sw.js 无法访问"
|
||||
grep -q 'disable stale PWA caches' /tmp/reactive-resume-sw.js \
|
||||
|| fail "direct 包 sw.js 未替换为清理旧缓存版本"
|
||||
|
||||
if command -v google-chrome >/dev/null 2>&1 && command -v node >/dev/null 2>&1; then
|
||||
log "使用 Chrome 执行 direct 包首页 JS"
|
||||
node <<'NODE'
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
|
||||
const port = 9444;
|
||||
const userData = "/tmp/reactive-resume-chrome-smoke";
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
|
||||
const chrome = spawn("google-chrome", [
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userData}`,
|
||||
"about:blank",
|
||||
], { stdio: ["ignore", "ignore", "ignore"] });
|
||||
|
||||
function request(path, method = "GET") {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: "127.0.0.1", port, path, method }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => body += chunk);
|
||||
res.on("end", () => resolve({ status: res.statusCode, body }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForChrome() {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const res = await request("/json/version");
|
||||
if (res.status === 200) return;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("Chrome remote debugging was not ready");
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await waitForChrome();
|
||||
const tab = JSON.parse((await request("/json/new?about:blank", "PUT")).body);
|
||||
const ws = new WebSocket(tab.webSocketDebuggerUrl);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
const errors = [];
|
||||
const indexRequests = [];
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id && pending.has(message.id)) {
|
||||
pending.get(message.id)(message);
|
||||
pending.delete(message.id);
|
||||
return;
|
||||
}
|
||||
if (message.method === "Runtime.exceptionThrown") {
|
||||
errors.push(message.params.exceptionDetails);
|
||||
}
|
||||
if (message.method === "Network.requestWillBeSent" && message.params.request.url.includes("index-")) {
|
||||
indexRequests.push(message.params.request.url);
|
||||
}
|
||||
};
|
||||
await new Promise((resolve) => { ws.onopen = resolve; });
|
||||
|
||||
function send(method, params = {}) {
|
||||
return new Promise((resolve) => {
|
||||
pending.set(++id, resolve);
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
await send("Runtime.enable");
|
||||
await send("Page.enable");
|
||||
await send("Network.enable");
|
||||
await send("Page.navigate", { url: "http://127.0.0.1:3004/" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
const result = await send("Runtime.evaluate", {
|
||||
expression: "document.body.innerText.includes('A free and open-source resume builder')",
|
||||
returnByValue: true,
|
||||
});
|
||||
ws.close();
|
||||
chrome.kill("SIGKILL");
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(JSON.stringify(errors.map((error) => ({
|
||||
text: error.text,
|
||||
url: error.url,
|
||||
line: error.lineNumber,
|
||||
column: error.columnNumber,
|
||||
exception: error.exception?.description,
|
||||
})), null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!result.result?.result?.value) {
|
||||
console.error("Chrome did not render the expected home page text");
|
||||
process.exit(1);
|
||||
}
|
||||
if (indexRequests.some((url) => /\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(url) && !/-rr[0-9a-z]+\.js$/.test(url))) {
|
||||
console.error(`Chrome still requested stale app shell: ${indexRequests.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
})().catch((error) => {
|
||||
chrome.kill("SIGKILL");
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
else
|
||||
log "未找到 google-chrome,跳过浏览器级首页 JS 测试"
|
||||
fi
|
||||
|
||||
log "离线检查 arm64/QNAP 镜像布局"
|
||||
ARM64_DIGEST="$(
|
||||
docker manifest inspect "$IMAGE_INDEX" \
|
||||
| node -e '
|
||||
let source = "";
|
||||
process.stdin.on("data", (chunk) => source += chunk);
|
||||
process.stdin.on("end", () => {
|
||||
const manifest = JSON.parse(source);
|
||||
const arm = manifest.manifests.find((item) => item.platform?.os === "linux" && item.platform?.architecture === "arm64");
|
||||
if (!arm) process.exit(2);
|
||||
process.stdout.write(arm.digest);
|
||||
});
|
||||
'
|
||||
)"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
CID="$(docker create --platform linux/arm64 "$IMAGE_INDEX" 2>/dev/null || true)"
|
||||
[ -n "$CID" ] || fail "无法创建 arm64 镜像容器用于离线检查"
|
||||
docker export "$CID" -o "$TMP_DIR/arm64-root.tar"
|
||||
docker rm "$CID" >/dev/null
|
||||
mkdir -p "$TMP_DIR/arm64-root"
|
||||
tar -xf "$TMP_DIR/arm64-root.tar" -C "$TMP_DIR/arm64-root"
|
||||
|
||||
if [ -f "$TMP_DIR/arm64-root/app/apps/server/dist/index.mjs" ]; then
|
||||
ARM64_SERVER_ENTRY="$TMP_DIR/arm64-root/app/apps/server/dist/index.mjs"
|
||||
ARM64_FILENAME_ENTRY="$ARM64_SERVER_ENTRY"
|
||||
ARM64_ASSETS_DIR="$TMP_DIR/arm64-root/app/apps/web/dist/assets"
|
||||
EXPECTED_ENTRYPOINT_PWD="$TMP_DIR/arm64-root/app"
|
||||
EXPECTED_ENTRYPOINT_ARGS="node apps/server/dist/index.mjs"
|
||||
elif [ -f "$TMP_DIR/arm64-root/app/apps/web/.output/server/index.mjs" ]; then
|
||||
ARM64_SERVER_ENTRY="$TMP_DIR/arm64-root/app/apps/web/.output/server/index.mjs"
|
||||
ARM64_FILENAME_ENTRY="$(grep -Rsl 'function generateFilename' "$TMP_DIR/arm64-root/app/apps/web/.output/server/_ssr" 2>/dev/null | head -n 1 || true)"
|
||||
[ -n "$ARM64_FILENAME_ENTRY" ] || fail "arm64 .output 布局中未找到 generateFilename SSR bundle"
|
||||
ARM64_ASSETS_DIR="$TMP_DIR/arm64-root/app/apps/web/.output/public/assets"
|
||||
EXPECTED_ENTRYPOINT_PWD="$TMP_DIR/arm64-root/app/apps/web"
|
||||
EXPECTED_ENTRYPOINT_ARGS="node .output/server/index.mjs"
|
||||
else
|
||||
find "$TMP_DIR/arm64-root/app" -maxdepth 6 \( -name index.mjs -o -name server.js -o -name main.js \) 2>/dev/null >&2 || true
|
||||
fail "arm64 镜像中未找到支持的服务入口"
|
||||
fi
|
||||
[ -d "$ARM64_ASSETS_DIR" ] || fail "arm64 镜像中未找到 assets 目录:$ARM64_ASSETS_DIR"
|
||||
|
||||
perl -0pe "
|
||||
s#/app/apps#$TMP_DIR/arm64-root/app/apps#g;
|
||||
s#for candidate in $TMP_DIR/arm64-root/app/apps/web /app#for candidate in $TMP_DIR/arm64-root/app/apps/web $TMP_DIR/arm64-root/app#g;
|
||||
s#find /app#find $TMP_DIR/arm64-root/app#g;
|
||||
s#APP_DIR=\"/app\"#APP_DIR=\"$TMP_DIR/arm64-root/app\"#g;
|
||||
s#under /app#under $TMP_DIR/arm64-root/app#g;
|
||||
" "$QNAP_PATCH_DIR/reactive-resume-runtime-patch.sh" > "$TMP_DIR/runtime-patch-arm64-test.sh"
|
||||
sh "$TMP_DIR/runtime-patch-arm64-test.sh" >/tmp/reactive-resume-arm64-runtime.log 2>&1 || {
|
||||
cat /tmp/reactive-resume-arm64-runtime.log >&2
|
||||
fail "arm64 离线运行 runtime patch 失败"
|
||||
}
|
||||
|
||||
grep -R 'rr-browser-buffer-polyfill' "$ARM64_ASSETS_DIR" >/dev/null \
|
||||
|| fail "arm64 public PDF bundle 未注入 Buffer polyfill"
|
||||
grep -R -F 'replace(/[\\/:*?"<>|]/g' "$ARM64_ASSETS_DIR" >/dev/null \
|
||||
|| fail "arm64 文件名 bundle 未改为按标题下载"
|
||||
if grep -R -E "index-[A-Za-z0-9_-]+\\.js\\?v=rr-filename-title" "$ARM64_ASSETS_DIR" >/dev/null 2>&1; then
|
||||
fail "arm64 补丁错误地给 index 主入口追加了 rr-filename-title 缓存标记"
|
||||
fi
|
||||
find "$TMP_DIR/arm64-root/app" -type f -exec grep -qE 'index-[A-Za-z0-9_-]+-rr[0-9a-z]+\.js' {} \; -print -quit \
|
||||
| grep -q . \
|
||||
|| fail "arm64 补丁未改用防缓存的 index 主入口文件名"
|
||||
find "$TMP_DIR/arm64-root/app" -path '*/sw.js' -type f -print0 | xargs -0 grep -l 'disable stale PWA caches' >/dev/null \
|
||||
|| fail "arm64 sw.js 未替换为清理旧缓存版本"
|
||||
grep -q 'function generateFilename(prefix, extension)' "$ARM64_FILENAME_ENTRY" \
|
||||
|| fail "arm64 server entry 未包含 generateFilename"
|
||||
grep -F 'filename.replace(/[\\/:*?"<>|]/g' "$ARM64_FILENAME_ENTRY" >/dev/null \
|
||||
|| fail "arm64 server entry 未改为按标题生成下载文件名"
|
||||
|
||||
perl -0pe "
|
||||
s#/app/apps#$TMP_DIR/arm64-root/app/apps#g;
|
||||
s#cd /app#cd $TMP_DIR/arm64-root/app#g;
|
||||
s#find /app#find $TMP_DIR/arm64-root/app#g;
|
||||
s#under /app#under $TMP_DIR/arm64-root/app#g;
|
||||
" "$QNAP_PATCH_DIR/reactive-resume-entrypoint.sh" > "$TMP_DIR/entrypoint-arm64-test.sh"
|
||||
mkdir -p "$TMP_DIR/fakebin"
|
||||
{
|
||||
printf '#!/bin/sh\n'
|
||||
printf 'printf "PWD=%%s\\n" "$PWD" > "%s/entrypoint-result.txt"\n' "$TMP_DIR"
|
||||
printf 'printf "ARGS=%%s\\n" "$*" >> "%s/entrypoint-result.txt"\n' "$TMP_DIR"
|
||||
} > "$TMP_DIR/fakebin/docker-entrypoint.sh"
|
||||
chmod +x "$TMP_DIR/fakebin/docker-entrypoint.sh"
|
||||
PATH="$TMP_DIR/fakebin:$PATH" sh "$TMP_DIR/entrypoint-arm64-test.sh" >/tmp/reactive-resume-arm64-entrypoint.log 2>&1 || {
|
||||
cat /tmp/reactive-resume-arm64-entrypoint.log >&2
|
||||
fail "arm64 entrypoint 选择测试失败"
|
||||
}
|
||||
|
||||
grep -q "PWD=$EXPECTED_ENTRYPOINT_PWD" "$TMP_DIR/entrypoint-result.txt" \
|
||||
|| fail "arm64 entrypoint 未切换到预期目录:$EXPECTED_ENTRYPOINT_PWD"
|
||||
grep -q "ARGS=$EXPECTED_ENTRYPOINT_ARGS" "$TMP_DIR/entrypoint-result.txt" \
|
||||
|| fail "arm64 entrypoint 未选择预期入口:$EXPECTED_ENTRYPOINT_ARGS"
|
||||
|
||||
log "清理 direct 测试容器"
|
||||
cleanup_direct
|
||||
|
||||
log "全部测试通过"
|
||||
printf 'direct health: %s\n' "$(cat /tmp/reactive-resume-health.json)"
|
||||
printf 'arm64 digest: %s\n' "$ARM64_DIGEST"
|
||||
45
工程分析/实现方案-2026-05-19-23-10-56.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 实现方案-2026-05-19-23-10-56
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-19-23-10-56.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将 Reactive Resume PDF 下载文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `scripts/patch-reactive-resume-filename.sh`
|
||||
- `工程分析/工程整体分析.md`
|
||||
- `工程分析/需求分析-2026-05-19-23-10-56.md`
|
||||
- `工程分析/实现方案-2026-05-19-23-10-56.md`
|
||||
- `工程分析/测试方案-2026-05-19-23-10-56.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 修改补丁脚本中的 shell 变量 `PDF_FILENAME`。
|
||||
2. 修改脚本中 Node 补丁逻辑的 `pdfFilename` 常量。
|
||||
3. 更新 `CACHE_BUST`,并让静态资源 import 替换逻辑兼容旧 cache bust。
|
||||
4. 用 `sh -n` 检查脚本语法。
|
||||
5. 执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`。
|
||||
6. 验证容器健康、文件内新文件名、HTTP 静态资源新文件名。
|
||||
7. 提交本次脚本和文档变更。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 回滚时可把 `PDF_FILENAME`、`pdfFilename` 和 `CACHE_BUST` 改回旧值,再重新执行脚本。
|
||||
- 若补丁失败,容器内已有 `.bak-filename` 备份可用于人工恢复。
|
||||
- 本次不改数据库、简历 JSON 和 Docker Compose 配置。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 更新:`scripts/patch-reactive-resume-filename.sh`
|
||||
- 新增/更新:`工程分析/` 本次文档与经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 显式暂存本次相关文件,避免混入无关变更。
|
||||
- commit message 使用:`2026-05-19-23-10-56 修改简历PDF下载文件名`
|
||||
- 部署通过运行补丁脚本完成,脚本内部会重启 Reactive Resume 容器。
|
||||
44
工程分析/实现方案-2026-05-19-23-23-50.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 实现方案-2026-05-19-23-23-50
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-19-23-23-50.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
回滚上一条错误指令,将 PDF 下载文件名恢复为 `ZhiboWang-Resume.pdf`。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `scripts/patch-reactive-resume-filename.sh`
|
||||
- `工程分析/需求分析-2026-05-19-23-23-50.md`
|
||||
- `工程分析/实现方案-2026-05-19-23-23-50.md`
|
||||
- `工程分析/测试方案-2026-05-19-23-23-50.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 将脚本中的 `PDF_FILENAME` 恢复为 `ZhiboWang-Resume.pdf`。
|
||||
2. 将 Node 补丁逻辑中的 `pdfFilename` 恢复为 `ZhiboWang-Resume.pdf`。
|
||||
3. 将 `CACHE_BUST` 与 `cacheBust` 恢复为 `rr-filename-20260519`。
|
||||
4. 执行 `sh -n` 做脚本语法检查。
|
||||
5. 重新执行补丁脚本,更新运行中容器并等待 healthy。
|
||||
6. 验证 HTTP 静态资源中包含旧文件名且不再包含中文文件名。
|
||||
7. 创建回滚 commit,并尝试推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 本次不改数据库、简历 JSON、Compose 或上传文件。
|
||||
- 如用户后续给出新的正确文件名,可再次改脚本变量并执行补丁脚本。
|
||||
- 当前选择新增回滚提交,保留历史可追溯性。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 更新:`scripts/patch-reactive-resume-filename.sh`
|
||||
- 新增:本次 `需求分析`、`实现方案`、`测试方案`
|
||||
- 更新:`工程分析/经验记录.md`
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- commit message 使用:`2026-05-19-23-23-50 回滚简历PDF下载文件名`
|
||||
- 部署通过执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1` 完成。
|
||||
32
工程分析/工程整体分析.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 工程整体分析
|
||||
|
||||
更新时间:2026-05-19-23-10-56
|
||||
|
||||
## 项目定位
|
||||
|
||||
本项目用于部署和维护个人 Reactive Resume 服务,服务公网访问地址为 `https://me.huijutec.cn`,公开简历入口为 `/audience/resume`。
|
||||
|
||||
## 当前结构
|
||||
|
||||
- `README.md`:部署、访问、安装包说明。
|
||||
- `compose.yml`:本机 Docker Compose 部署,包含 Postgres、Reactive Resume、frpc。
|
||||
- `compose-Nas.yml`:QNAP/NAS 部署版本。
|
||||
- `scripts/patch-reactive-resume-filename.sh`:对运行中 Reactive Resume 容器打补丁,控制 PDF 下载文件名,并修正静态资源 cache bust 与 Nitro manifest。
|
||||
- `scripts/patch-reactive-resume-glalie-layout.sh`:简历布局补丁。
|
||||
- `scripts/patch-reactive-resume-service-worker-cache.sh`:Service Worker 缓存补丁。
|
||||
- `packages/`:安装包目录。
|
||||
- `dist/`:发布归档。
|
||||
- `生成简历/`:简历源数据、备份、生成 PDF 和展示素材。
|
||||
|
||||
## 当前运行环境
|
||||
|
||||
- Docker 容器 `reactive-resume-reactive-resume-1` 运行 Reactive Resume,映射 `127.0.0.1:3003 -> 3000`。
|
||||
- 容器 `reactive-resume-frpc-1` 负责公网映射。
|
||||
- `origin` 远端为 Gitea HTTP 地址:`http://192.168.31.5:5002/admin/Reactive_Resume.git`。
|
||||
|
||||
## 维护注意事项
|
||||
|
||||
- 下载文件名由容器内打补丁脚本控制,不是普通业务源码直接控制。
|
||||
- 修改 PDF 文件名后需要更新 cache bust,防止浏览器继续使用旧静态资源。
|
||||
- 补丁脚本会修改容器内 `/app/apps/web/.output` 文件并重启容器,执行后必须等待健康检查通过。
|
||||
- Gitea HTTP 远端可能缺少交互式凭据,推送失败时应保留本地 commit,不要在命令行写入账号密码。
|
||||
44
工程分析/测试方案-2026-05-19-23-10-56.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 测试方案-2026-05-19-23-10-56
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-19-23-10-56.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `sh -n scripts/patch-reactive-resume-filename.sh`。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`。
|
||||
- 确认容器 `reactive-resume-reactive-resume-1` 恢复 healthy。
|
||||
|
||||
## 业务验证
|
||||
|
||||
- 在容器内搜索 `王志博-医工智能外科-简历.pdf`。
|
||||
- 通过 `curl http://127.0.0.1:3003/assets/file-D5WsIgJH.js` 验证静态资源已包含新文件名。
|
||||
- 通过 `curl http://127.0.0.1:3003/api/health` 验证服务健康。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 本地创建包含时间戳和简要描述的 commit。
|
||||
- 尝试 `git push origin main`。
|
||||
- 若 Gitea HTTP 凭据不可用,记录失败原因并保留本地 commit。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 浏览器缓存可能保留旧静态资源,因此本次必须更新 cache bust。
|
||||
- Reactive Resume 镜像升级后构建文件哈希可能变化,脚本内固定文件路径需要重新定位。
|
||||
|
||||
## 实际执行结果
|
||||
|
||||
- `sh -n scripts/patch-reactive-resume-filename.sh`:通过。
|
||||
- `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`:通过。
|
||||
- 容器健康状态:`reactive-resume-reactive-resume-1` 为 `healthy`。
|
||||
- 容器内验证:`/app/apps/web/.output/public/assets/file-D5WsIgJH.js` 和 `/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs` 均包含 `王志博-医工智能外科-简历.pdf`。
|
||||
- HTTP 静态资源验证:`curl http://127.0.0.1:3003/assets/file-D5WsIgJH.js` 可检索到 `王志博-医工智能外科-简历.pdf`。
|
||||
- Cache bust 验证:业务页面 import 已指向 `file-D5WsIgJH.js?v=rr-filename-20260519-cn`。
|
||||
- 服务健康验证:`curl http://127.0.0.1:3003/api/health` 返回 `status: healthy`。
|
||||
- 公开简历页验证:`curl -I http://127.0.0.1:3003/audience/resume` 返回 `HTTP/1.1 200 OK`。
|
||||
- Git 本地备份 commit:已创建,提交信息为 `2026-05-19-23-10-56 修改简历PDF下载文件名`。
|
||||
- Gitea 远端推送:执行 `git push origin main` 时失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行写入账号密码。
|
||||
48
工程分析/测试方案-2026-05-19-23-23-50.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 测试方案-2026-05-19-23-23-50
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-19-23-23-50.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `sh -n scripts/patch-reactive-resume-filename.sh`。
|
||||
- 搜索脚本确认包含 `ZhiboWang-Resume.pdf`。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 执行 `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`。
|
||||
- 确认容器 `reactive-resume-reactive-resume-1` 为 `healthy`。
|
||||
|
||||
## 业务验证
|
||||
|
||||
- 验证容器内 public JS 与 SSR 文件包含 `ZhiboWang-Resume.pdf`。
|
||||
- 验证 HTTP 静态资源 `/assets/file-D5WsIgJH.js` 包含 `ZhiboWang-Resume.pdf`。
|
||||
- 验证 HTTP 静态资源不再包含 `王志博-医工智能外科简历.pdf` 或 `王志博-医工智能外科-简历.pdf`。
|
||||
- 验证 `http://127.0.0.1:3003/api/health` 返回 healthy。
|
||||
- 验证 `http://127.0.0.1:3003/audience/resume` 返回 200。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 创建本地回滚 commit。
|
||||
- 尝试 `git push origin main`。
|
||||
- 若 HTTP 凭据不可用,记录失败原因。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 浏览器缓存可能短暂保留旧中文 cache bust;脚本恢复 `rr-filename-20260519` 后应重新更新 importer 与 manifest。
|
||||
|
||||
## 实际执行结果
|
||||
|
||||
- `sh -n scripts/patch-reactive-resume-filename.sh`:通过。
|
||||
- 脚本搜索:`PDF_FILENAME` 与 `pdfFilename` 均恢复为 `ZhiboWang-Resume.pdf`,`CACHE_BUST` 与 `cacheBust` 均恢复为 `rr-filename-20260519`。
|
||||
- `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`:通过。
|
||||
- 容器状态:`reactive-resume-reactive-resume-1` 为 `healthy`。
|
||||
- 容器内验证:public JS 与 SSR 文件均包含 `ZhiboWang-Resume.pdf`。
|
||||
- 容器内中文残留验证:public JS 与 SSR 文件未检出 `王志博-医工智能外科`。
|
||||
- HTTP 静态资源验证:`/assets/file-D5WsIgJH.js` 返回内容包含 `ZhiboWang-Resume.pdf`。
|
||||
- Cache bust 验证:业务页面 import 已恢复为 `file-D5WsIgJH.js?v=rr-filename-20260519`。
|
||||
- 服务健康验证:`curl http://127.0.0.1:3003/api/health` 返回 `status: healthy`。
|
||||
- 公开简历页验证:`curl -I http://127.0.0.1:3003/audience/resume` 返回 `HTTP/1.1 200 OK`。
|
||||
- Git 本地备份 commit:已创建,提交信息为 `2026-05-19-23-23-50 回滚简历PDF下载文件名`。
|
||||
- Gitea 远端推送:执行 `git push origin main` 时失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行写入账号密码。
|
||||
57
工程分析/经验记录.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 经验记录
|
||||
|
||||
本文件用于记录个人简历构建项目修改过程中的关键问题与解决方案。每条经验使用四段式结构。
|
||||
|
||||
## 2026-05-19-23-10-56 Reactive Resume PDF 下载文件名修改
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户希望浏览器下载 PDF 时的文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`,当前 DICOM 项目中没有该文件名,需要定位真实项目。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
该下载文件名不是 DICOM 项目功能,而是个人简历构建项目中的 Reactive Resume 容器补丁脚本硬编码。Reactive Resume 的前端 public JS 和 SSR 文件都需要同步修改,且浏览器可能缓存旧静态资源。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
在 `/home/wkmgc/Desktop/个人材料编写/个人简历构建` 中定位到 `scripts/patch-reactive-resume-filename.sh`,将 shell 与 Node 补丁逻辑中的 PDF 文件名统一改为中文文件名;同时把 cache bust 更新为 `rr-filename-20260519-cn`,并让 import 替换逻辑兼容旧 cache bust。执行脚本后容器重启并恢复 healthy。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
涉及线上下载文件名时,应先全局搜索目标文件名并确认真实项目;修改容器补丁脚本后必须立即运行脚本、验证容器内 public/SSR 文件、验证 HTTP 静态资源和服务健康,避免只改脚本未生效。
|
||||
|
||||
## 2026-05-19-23-10-56 Reactive Resume Gitea 推送凭据缺失
|
||||
|
||||
A. 具体问题
|
||||
|
||||
本次本地备份 commit 已创建,但执行 `git push origin main` 时失败,Git 提示无法读取 `http://192.168.31.5:5002` 的用户名。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
当前 `origin` 使用 HTTP Gitea 地址,执行环境没有交互式凭据输入,也没有已配置的凭据助手。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
保留本地 commit,不把账号密码写入命令、文档或 Git remote。将推送失败写入测试方案和经验记录,后续由用户配置安全凭据或改为 SSH remote 后再推送。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
Gitea 推送前先检查认证方式。优先使用 SSH 或安全凭据助手;如果 HTTP remote 需要 token,应通过安全环境变量或凭据管理器提供,不要直接拼进 URL。
|
||||
|
||||
## 2026-05-19-23-23-50 回滚错误的 PDF 下载文件名指令
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户说明上一条修改 PDF 下载文件名的指令写错了,需要撤销中文文件名变更。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
上一条需求将 Reactive Resume 的 PDF 下载文件名从 `ZhiboWang-Resume.pdf` 改为中文文件名;之后最新提交又把中文文件名调整为不带短横线版本。只回滚最新提交会仍然保留中文文件名,不能真正回到原始状态。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
以原始文件名 `ZhiboWang-Resume.pdf` 为目标,直接恢复 `scripts/patch-reactive-resume-filename.sh` 中 shell 与 Node 两处文件名常量,并恢复 cache bust 为 `rr-filename-20260519`。随后重新执行补丁脚本,让运行中容器 public JS 与 SSR 文件同步恢复。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
用户要求回滚时,先确认要回到哪一个历史状态,而不是只撤销最近一个 commit。涉及线上容器补丁时,Git 文件回滚后必须重新执行补丁脚本并验证 HTTP 静态资源。
|
||||
42
工程分析/需求分析-2026-05-19-23-10-56.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 需求分析-2026-05-19-23-10-56
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-19-23-10-56
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求将下载的 PDF 文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 让用户从 Reactive Resume 网页下载 PDF 时,浏览器保存文件名显示为中文简历名称。
|
||||
- 保持现有简历内容、部署地址和其他导出格式不变。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:当前运行的 Reactive Resume 服务和既有补丁脚本。
|
||||
- 输出:更新后的补丁脚本、运行中容器内已生效的新 PDF 下载文件名。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `scripts/patch-reactive-resume-filename.sh`
|
||||
- 运行中 Docker 容器 `reactive-resume-reactive-resume-1` 的静态资源与 SSR 文件。
|
||||
- 新增本次工程分析文档与经验记录。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 当前 DICOM 项目内没有该下载文件名,真实目标位于 `/home/wkmgc/Desktop/个人材料编写/个人简历构建`。
|
||||
- 修改后需要重新执行补丁脚本并等待容器健康。
|
||||
- 不能把 Gitea 凭据写入脚本、remote URL 或命令历史。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 若只改脚本不执行,线上下载文件名不会立即变化。
|
||||
- 若不更新 cache bust,浏览器可能继续使用旧 JS 静态资源。
|
||||
- 若补丁脚本匹配不到新的构建文件名,容器补丁可能失败。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- 用户说的“下载的 PDF”指当前 Reactive Resume 公开简历的 PDF 下载文件名。
|
||||
- 只改 PDF 文件名,不改简历标题、数据库内容或其他导出格式。
|
||||
41
工程分析/需求分析-2026-05-19-23-23-50.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 需求分析-2026-05-19-23-23-50
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-19-23-23-50
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户说明上一条“修改 PDF 下载文件名”的指令写错了,要求回滚。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 将 Reactive Resume PDF 下载文件名恢复到改名前的 `ZhiboWang-Resume.pdf`。
|
||||
- 同步恢复运行中容器的 public JS 与 SSR 下载文件名逻辑。
|
||||
- 保留本次回滚记录,避免后续误以为中文文件名仍是目标状态。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:当前最新脚本、当前 Docker 容器、相关 commit `d2edebe` 与 `d77954b`。
|
||||
- 输出:脚本恢复旧文件名、运行中服务恢复旧下载名、回滚分析与经验记录。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `scripts/patch-reactive-resume-filename.sh`
|
||||
- 运行中容器 `reactive-resume-reactive-resume-1`
|
||||
- `工程分析/` 本次回滚文档与经验记录
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 使用非破坏性新增提交完成回滚,不使用 `git reset --hard`。
|
||||
- 不删除历史审计文档,新增回滚文档说明原因。
|
||||
- Gitea HTTP 远端可能仍缺少凭据,推送失败需要记录。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 如果只改 Git 脚本,不重新执行脚本,线上容器仍会保持中文文件名。
|
||||
- 如果只回滚最新 `d77954b`,文件名会退到 `王志博-医工智能外科-简历.pdf`,仍然不是原始文件名。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- “回滚一下”指回到用户提出中文文件名前的状态,即 `ZhiboWang-Resume.pdf`。
|
||||