From 9ae9b93ca7eb8b51574b44b67d70c94b43ac6dc2 Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 16:02:13 +0800 Subject: [PATCH] Refine production compose runtime config --- .env.release.example | 39 ++++++++++++- README.md | 24 +++++--- compose.release.yml | 37 +++++++++++-- docs/01-system-architecture.md | 10 ++-- docs/09-deployment.md | 38 +++++++------ .../bootstrap/BootstrapAdminProperties.java | 28 ++++++++++ .../bootstrap/DockerSeedDataRunner.java | 36 ++++++++---- .../src/main/resources/application.yml | 55 +++++++++++-------- .../skillhub/storage/S3StorageProperties.java | 14 +++++ .../skillhub/storage/S3StorageService.java | 13 ++++- web/Dockerfile | 5 +- web/docker-entrypoint.d/30-runtime-config.sh | 9 +++ web/index.html | 2 +- web/{nginx.conf => nginx.conf.template} | 17 ++++-- web/runtime-config.js.template | 4 ++ web/src/api/client.ts | 40 +++++++++++++- web/src/bootstrap.ts | 20 +++++++ 17 files changed, 308 insertions(+), 83 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java create mode 100644 web/docker-entrypoint.d/30-runtime-config.sh rename web/{nginx.conf => nginx.conf.template} (67%) create mode 100644 web/runtime-config.js.template create mode 100644 web/src/bootstrap.ts diff --git a/.env.release.example b/.env.release.example index c41ccee5..21d73140 100644 --- a/.env.release.example +++ b/.env.release.example @@ -4,15 +4,48 @@ SKILLHUB_VERSION=edge SKILLHUB_SERVER_IMAGE=ghcr.io/iflytek/skillhub-server SKILLHUB_WEB_IMAGE=ghcr.io/iflytek/skillhub-web +# Public entrypoint seen by browsers/CLI, no trailing slash. +SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com + +# Frontend usually keeps this empty and proxies to the backend through nginx. +SKILLHUB_WEB_API_BASE_URL= +SKILLHUB_API_UPSTREAM=http://server:8080 + +POSTGRES_BIND_ADDRESS=127.0.0.1 POSTGRES_PORT=5432 POSTGRES_DB=skillhub POSTGRES_USER=skillhub -POSTGRES_PASSWORD=skillhub_demo +POSTGRES_PASSWORD=change-this-postgres-password +REDIS_BIND_ADDRESS=127.0.0.1 REDIS_PORT=6379 API_PORT=8080 WEB_PORT=80 +SESSION_COOKIE_SECURE=true + +# Production default is external S3/OSS compatible storage. +SKILLHUB_STORAGE_PROVIDER=s3 +SKILLHUB_STORAGE_S3_ENDPOINT=https://oss-cn-example.aliyuncs.com +SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT= +SKILLHUB_STORAGE_S3_BUCKET=skillhub-prod +SKILLHUB_STORAGE_S3_ACCESS_KEY=replace-me +SKILLHUB_STORAGE_S3_SECRET_KEY=replace-me +SKILLHUB_STORAGE_S3_REGION=cn-shanghai +SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false +SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false +SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY=PT10M + +# Bootstrap local admin account for first login. Rotate or disable after initial setup. +BOOTSTRAP_ADMIN_ENABLED=true +BOOTSTRAP_ADMIN_USER_ID=docker-admin +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_PASSWORD=replace-this-admin-password +BOOTSTRAP_ADMIN_DISPLAY_NAME=Platform Admin +BOOTSTRAP_ADMIN_EMAIL=admin@example.com + +# Optional override. Defaults to ${SKILLHUB_PUBLIC_BASE_URL}/device. +DEVICE_AUTH_VERIFICATION_URI= # Optional: configure real GitHub OAuth before exposing the stack to other users. -OAUTH2_GITHUB_CLIENT_ID=local-placeholder -OAUTH2_GITHUB_CLIENT_SECRET=local-placeholder +OAUTH2_GITHUB_CLIENT_ID= +OAUTH2_GITHUB_CLIENT_SECRET= diff --git a/README.md b/README.md index 172e26e1..85200f70 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d Then open: -- Web UI: `http://localhost` +- Web UI: `SKILLHUB_PUBLIC_BASE_URL` 对应的地址 - Backend API: `http://localhost:8080` Stop it with: @@ -139,16 +139,22 @@ docker compose --env-file .env.release -f compose.release.yml down The runtime stack uses its own Compose project name, so it does not collide with containers from `make dev-all`. -The runtime uses the existing `local,docker` profile combination so it -is immediately usable with the same mock-auth flow as local development. -Available seeded users: +The production Compose stack now defaults to the `docker` profile only. +It does not enable local mock auth. Instead, the backend bootstraps a +local admin account from environment variables for the first login: -- `local-user` -- `local-admin` +- username: `BOOTSTRAP_ADMIN_USERNAME` +- password: `BOOTSTRAP_ADMIN_PASSWORD` -Pass `X-Mock-User-Id` to the backend when you need an authenticated -session without configuring GitHub OAuth. If the GHCR package remains -private, run `docker login ghcr.io` before `docker compose up -d`. +Recommended production baseline: + +- set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint +- keep PostgreSQL / Redis bound to `127.0.0.1` +- use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` +- rotate or disable the bootstrap admin after initial setup + +If the GHCR package remains private, run `docker login ghcr.io` before +`docker compose up -d`. ### Monitoring diff --git a/compose.release.yml b/compose.release.yml index 04c04395..328445a6 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -3,7 +3,7 @@ services: image: postgres:16-alpine restart: unless-stopped ports: - - "${POSTGRES_PORT:-5432}:5432" + - "${POSTGRES_BIND_ADDRESS:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432" environment: POSTGRES_DB: ${POSTGRES_DB:-skillhub} POSTGRES_USER: ${POSTGRES_USER:-skillhub} @@ -20,7 +20,10 @@ services: image: redis:7-alpine restart: unless-stopped ports: - - "${REDIS_PORT:-6379}:6379" + - "${REDIS_BIND_ADDRESS:-127.0.0.1}:${REDIS_PORT:-6379}:6379" + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -33,13 +36,32 @@ services: ports: - "${API_PORT:-8080}:8080" environment: - SPRING_PROFILES_ACTIVE: local,docker + SPRING_PROFILES_ACTIVE: docker SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-skillhub} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-skillhub} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-skillhub_demo} - SPRING_DATA_REDIS_HOST: redis - SPRING_DATA_REDIS_PORT: 6379 + REDIS_HOST: redis + REDIS_PORT: 6379 + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} + SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} + DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-} + SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3} STORAGE_BASE_PATH: /var/lib/skillhub/storage + SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-} + SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT: ${SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT:-} + SKILLHUB_STORAGE_S3_BUCKET: ${SKILLHUB_STORAGE_S3_BUCKET:-skillhub} + SKILLHUB_STORAGE_S3_ACCESS_KEY: ${SKILLHUB_STORAGE_S3_ACCESS_KEY:-} + SKILLHUB_STORAGE_S3_SECRET_KEY: ${SKILLHUB_STORAGE_S3_SECRET_KEY:-} + SKILLHUB_STORAGE_S3_REGION: ${SKILLHUB_STORAGE_S3_REGION:-us-east-1} + SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:-false} + SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:-false} + SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:-PT10M} + BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-true} + BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin} + BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-ChangeMe!2026} + BOOTSTRAP_ADMIN_DISPLAY_NAME: ${BOOTSTRAP_ADMIN_DISPLAY_NAME:-Admin} + BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local} OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder} OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder} volumes: @@ -61,6 +83,10 @@ services: restart: unless-stopped ports: - "${WEB_PORT:-80}:80" + environment: + SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080} + SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-} + SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} depends_on: server: condition: service_healthy @@ -73,4 +99,5 @@ services: volumes: postgres_data: + redis_data: skillhub_storage: diff --git a/docs/01-system-architecture.md b/docs/01-system-architecture.md index ec754373..ffabb460 100644 --- a/docs/01-system-architecture.md +++ b/docs/01-system-architecture.md @@ -112,7 +112,8 @@ skillhub/ │ └── Dockerfile # 后端多阶段构建 ├── web/ # React 前端 │ ├── Dockerfile # 前端多阶段构建 -│ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理) +│ ├── nginx.conf.template # Nginx 运行时模板 +│ └── runtime-config.js.template # 前端运行时环境变量模板 ├── docker-compose.yml # 本地开发依赖服务(PostgreSQL/Redis/MinIO) ├── compose.release.yml # 单机运行时编排(发布镜像 + PostgreSQL + Redis) ├── .env.release.example # 单机运行时环境变量模板 @@ -137,9 +138,10 @@ skillhub/ - `http://localhost/api/*` → Web 容器反向代理到 Spring Boot - `http://localhost:8080/actuator/health` → 后端健康检查 -单机运行时使用 `local,docker` profile 组合: -- `local` 提供 mock 登录和种子账号,保证拉起即用 -- `docker` 负责将数据库、Redis 地址切换到 Compose 网络 +单机运行时默认使用 `docker` profile: +- `docker` 负责容器运行时初始化,例如首个管理员账户 +- 数据库、Redis、对象存储、站点公网地址都通过环境变量注入 +- 生产环境不启用 `local` profile,因此不会暴露 mock 登录旁路 ## 9. 分布式环境要求 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 9b5b13ea..1d0fe04a 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -38,31 +38,33 @@ 说明: - Web 容器提供静态资源,并将 `/api/*`、`/oauth2/*`、`/.well-known/*` 反代到后端 -- 后端运行 `local,docker` profile 组合 -- 技能包文件默认落在容器卷 `skillhub_storage`,保证单机环境开箱即用 +- 后端默认运行 `docker` profile,不再启用本地 mock 登录 +- PostgreSQL / Redis 默认只绑定 `127.0.0.1` +- 对象存储推荐使用外部 S3 / OSS,通过环境变量注入 ## 3 Profile 约定 | Profile | 用途 | 说明 | |---------|------|------| | `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | -| `docker` | 容器网络适配 | 将数据库和 Redis 地址切换到 Compose 内网 | +| `docker` | 容器运行时能力 | 启用容器内启动用管理员账号初始化等运行时行为 | -单机交付环境使用 `SPRING_PROFILES_ACTIVE=local,docker`,原因很明确: +单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下: -- 这是当前唯一能保证“镜像拉起后直接可用”的 profile 组合 -- 用户无需先配置 GitHub OAuth,先用 mock 身份即可浏览和联调主要流程 -- 后续如果引入专用 `runtime` / `demo` profile,可以替换这层组合,但当前方案不再新增第三条部署路径 +- 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力 +- 容器环境仍然可以通过 `docker` profile 初始化首个管理员账户 +- 数据库、Redis、OSS、站点公网地址全部改为环境变量优先 -默认可用账号: +默认首登账号来源于环境变量: -- `local-user` -- `local-admin` +- `BOOTSTRAP_ADMIN_USERNAME` +- `BOOTSTRAP_ADMIN_PASSWORD` -鉴权方式: +建议: -- 向后端请求携带 `X-Mock-User-Id: local-user` -- 或 `X-Mock-User-Id: local-admin` +- 完成首次登录后立即修改管理员密码 +- 如果已有外部身份源,可将 `BOOTSTRAP_ADMIN_ENABLED=false` +- `SKILLHUB_PUBLIC_BASE_URL` 应配置为最终 HTTPS 域名,避免 OAuth / Cookie / 设备码链接异常 ## 4 开发环境 @@ -99,7 +101,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d 默认访问地址: -- Web UI: `http://localhost` +- Web UI: `SKILLHUB_PUBLIC_BASE_URL` - Backend API: `http://localhost:8080` ### 5.2 关键文件 @@ -107,10 +109,11 @@ docker compose --env-file .env.release -f compose.release.yml up -d - `compose.release.yml` - 使用发布镜像,不在用户机器上执行本地构建 - 负责拉起 PostgreSQL、Redis、server、web - - 使用独立 Compose project name,避免与开发环境容器互相污染 + - PostgreSQL、Redis 默认只绑定到 `127.0.0.1` + - Web 和后端都支持运行时环境变量注入,不需要为每个环境重建镜像 - `.env.release.example` - 运行时变量模板 - - 包含镜像名、镜像版本、端口和数据库凭证 + - 包含镜像名、镜像版本、端口、数据库凭证、外部 OSS、站点公网地址和首登管理员参数 ### 5.3 镜像标签约定 @@ -160,6 +163,9 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 使用 `.env.release` 管理 Compose 变量 - 如果 GHCR 包保持私有,用户需要先 `docker login ghcr.io` +- 推荐将敏感变量放入 CI/CD Secret 或主机上的受控 `.env.release` +- 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入 +- 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入 - 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET` ## 8 可观测性 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java new file mode 100644 index 00000000..1cf615a6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.bootstrap; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.bootstrap.admin") +public class BootstrapAdminProperties { + private boolean enabled = true; + private String userId = "docker-admin"; + private String username = "admin"; + private String password = "ChangeMe!2026"; + private String displayName = "Admin"; + private String email = "admin@skillhub.local"; + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java index 1879e54f..6caf75ed 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java @@ -29,13 +29,9 @@ import org.springframework.transaction.annotation.Transactional; @Component @Profile("docker") public class DockerSeedDataRunner implements ApplicationRunner { - - private static final String ADMIN_USER_ID = "docker-admin"; - private static final String ADMIN_USERNAME = "admin"; - private static final String ADMIN_PASSWORD = "Admin@2026"; - private static final Logger log = LoggerFactory.getLogger(DockerSeedDataRunner.class); + private final BootstrapAdminProperties bootstrapAdminProperties; private final UserAccountRepository userAccountRepository; private final LocalCredentialRepository localCredentialRepository; private final RoleRepository roleRepository; @@ -44,13 +40,15 @@ public class DockerSeedDataRunner implements ApplicationRunner { private final NamespaceMemberRepository namespaceMemberRepository; private final PasswordEncoder passwordEncoder; - public DockerSeedDataRunner(UserAccountRepository userAccountRepository, + public DockerSeedDataRunner(BootstrapAdminProperties bootstrapAdminProperties, + UserAccountRepository userAccountRepository, LocalCredentialRepository localCredentialRepository, RoleRepository roleRepository, UserRoleBindingRepository userRoleBindingRepository, NamespaceRepository namespaceRepository, NamespaceMemberRepository namespaceMemberRepository, PasswordEncoder passwordEncoder) { + this.bootstrapAdminProperties = bootstrapAdminProperties; this.userAccountRepository = userAccountRepository; this.localCredentialRepository = localCredentialRepository; this.roleRepository = roleRepository; @@ -63,20 +61,36 @@ public class DockerSeedDataRunner implements ApplicationRunner { @Override @Transactional public void run(ApplicationArguments args) { - if (localCredentialRepository.existsByUsernameIgnoreCase(ADMIN_USERNAME)) { + if (!bootstrapAdminProperties.isEnabled()) { + log.info("Docker bootstrap admin is disabled"); + return; + } + if (localCredentialRepository.existsByUsernameIgnoreCase(bootstrapAdminProperties.getUsername())) { log.info("Docker seed data already exists, skipping"); return; } // 1. Create admin user account - UserAccount admin = userAccountRepository.findById(ADMIN_USER_ID) + UserAccount admin = userAccountRepository.findById(bootstrapAdminProperties.getUserId()) .orElseGet(() -> userAccountRepository.save( - new UserAccount(ADMIN_USER_ID, "Admin", "admin@skillhub.dev", null) + new UserAccount( + bootstrapAdminProperties.getUserId(), + bootstrapAdminProperties.getDisplayName(), + bootstrapAdminProperties.getEmail(), + null + ) )); + admin.setDisplayName(bootstrapAdminProperties.getDisplayName()); + admin.setEmail(bootstrapAdminProperties.getEmail()); + admin = userAccountRepository.save(admin); // 2. Create local credential (username/password) localCredentialRepository.save( - new LocalCredential(admin.getId(), ADMIN_USERNAME, passwordEncoder.encode(ADMIN_PASSWORD)) + new LocalCredential( + admin.getId(), + bootstrapAdminProperties.getUsername(), + passwordEncoder.encode(bootstrapAdminProperties.getPassword()) + ) ); // 3. Assign SUPER_ADMIN role @@ -95,6 +109,6 @@ public class DockerSeedDataRunner implements ApplicationRunner { namespaceMemberRepository.save(new NamespaceMember(globalNs.getId(), admin.getId(), NamespaceRole.OWNER)); } - log.info("Docker seed data initialized — admin account: {} / {}", ADMIN_USERNAME, ADMIN_PASSWORD); + log.info("Docker seed data initialized for admin account: {}", bootstrapAdminProperties.getUsername()); } } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 6c8468d3..cd55e8f1 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -1,6 +1,7 @@ server: port: 8080 shutdown: graceful + forward-headers-strategy: framework servlet: session: cookie: @@ -27,19 +28,20 @@ spring: enabled: true locations: classpath:db/migration datasource: - url: jdbc:postgresql://localhost:5432/skillhub - username: skillhub - password: skillhub_dev + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/skillhub} + username: ${SPRING_DATASOURCE_USERNAME:skillhub} + password: ${SPRING_DATASOURCE_PASSWORD:skillhub_dev} hikari: - maximum-pool-size: 10 + maximum-pool-size: ${DB_POOL_MAX_SIZE:10} data: redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} + host: ${SPRING_DATA_REDIS_HOST:${REDIS_HOST:localhost}} + port: ${SPRING_DATA_REDIS_PORT:${REDIS_PORT:6379}} + password: ${SPRING_DATA_REDIS_PASSWORD:${REDIS_PASSWORD:}} session: store-type: redis redis: - namespace: skillhub:session + namespace: ${SESSION_REDIS_NAMESPACE:skillhub:session} security: oauth2: client: @@ -57,12 +59,24 @@ spring: max-request-size: 100MB skillhub: + public: + base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: mode: OPEN storage: - provider: local + provider: ${SKILLHUB_STORAGE_PROVIDER:local} local: base-path: ${STORAGE_BASE_PATH:/tmp/skillhub-storage} + s3: + endpoint: ${SKILLHUB_STORAGE_S3_ENDPOINT:} + public-endpoint: ${SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT:} + bucket: ${SKILLHUB_STORAGE_S3_BUCKET:skillhub} + access-key: ${SKILLHUB_STORAGE_S3_ACCESS_KEY:} + secret-key: ${SKILLHUB_STORAGE_S3_SECRET_KEY:} + region: ${SKILLHUB_STORAGE_S3_REGION:us-east-1} + force-path-style: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:true} + auto-create-bucket: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:false} + presign-expiry: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:PT10M} search: engine: postgres rebuild-on-startup: false @@ -71,6 +85,16 @@ skillhub: max-single-file-size: 1048576 # 1MB max-package-size: 104857600 # 100MB allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.js,.ts,.py,.sh,.png,.jpg,.svg + device-auth: + verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device} + bootstrap: + admin: + enabled: ${BOOTSTRAP_ADMIN_ENABLED:true} + user-id: ${BOOTSTRAP_ADMIN_USER_ID:docker-admin} + username: ${BOOTSTRAP_ADMIN_USERNAME:admin} + password: ${BOOTSTRAP_ADMIN_PASSWORD:ChangeMe!2026} + display-name: ${BOOTSTRAP_ADMIN_DISPLAY_NAME:Admin} + email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local} management: endpoints: @@ -86,18 +110,3 @@ management: export: prometheus: enabled: true - ---- -# Docker profile -spring: - config: - activate: - on-profile: docker - datasource: - url: jdbc:postgresql://${DB_HOST:postgres}:${DB_PORT:5432}/${DB_NAME:skillhub} - username: ${DB_USER:skillhub} - password: ${DB_PASS:skillhub_dev} - data: - redis: - host: ${REDIS_HOST:redis} - port: ${REDIS_PORT:6379} diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java index 05393e7d..da961ee5 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java @@ -3,17 +3,25 @@ package com.iflytek.skillhub.storage; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.time.Duration; + @Component @ConfigurationProperties(prefix = "skillhub.storage.s3") public class S3StorageProperties { private String endpoint; + private String publicEndpoint; private String bucket = "skillhub"; private String accessKey; private String secretKey; private String region = "us-east-1"; + private boolean forcePathStyle = true; + private boolean autoCreateBucket = false; + private Duration presignExpiry = Duration.ofMinutes(10); public String getEndpoint() { return endpoint; } public void setEndpoint(String endpoint) { this.endpoint = endpoint; } + public String getPublicEndpoint() { return publicEndpoint; } + public void setPublicEndpoint(String publicEndpoint) { this.publicEndpoint = publicEndpoint; } public String getBucket() { return bucket; } public void setBucket(String bucket) { this.bucket = bucket; } public String getAccessKey() { return accessKey; } @@ -22,4 +30,10 @@ public class S3StorageProperties { public void setSecretKey(String secretKey) { this.secretKey = secretKey; } public String getRegion() { return region; } public void setRegion(String region) { this.region = region; } + public boolean isForcePathStyle() { return forcePathStyle; } + public void setForcePathStyle(boolean forcePathStyle) { this.forcePathStyle = forcePathStyle; } + public boolean isAutoCreateBucket() { return autoCreateBucket; } + public void setAutoCreateBucket(boolean autoCreateBucket) { this.autoCreateBucket = autoCreateBucket; } + public Duration getPresignExpiry() { return presignExpiry; } + public void setPresignExpiry(Duration presignExpiry) { this.presignExpiry = presignExpiry; } } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java index ebca94f7..dbb198cb 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java @@ -36,7 +36,7 @@ public class S3StorageService implements ObjectStorageService { .region(Region.of(properties.getRegion())) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))) - .forcePathStyle(true); + .forcePathStyle(properties.isForcePathStyle()); if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { builder.endpointOverride(URI.create(properties.getEndpoint())); } @@ -45,7 +45,9 @@ public class S3StorageService implements ObjectStorageService { .region(Region.of(properties.getRegion())) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))); - if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { + if (properties.getPublicEndpoint() != null && !properties.getPublicEndpoint().isBlank()) { + presignerBuilder.endpointOverride(URI.create(properties.getPublicEndpoint())); + } else if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { presignerBuilder.endpointOverride(URI.create(properties.getEndpoint())); } this.s3Presigner = presignerBuilder.build(); @@ -53,6 +55,10 @@ public class S3StorageService implements ObjectStorageService { } private void ensureBucketExists() { + if (!properties.isAutoCreateBucket()) { + s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); + return; + } try { s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); } catch (NoSuchBucketException e) { log.info("Bucket '{}' does not exist, creating...", properties.getBucket()); @@ -90,9 +96,10 @@ public class S3StorageService implements ObjectStorageService { @Override public String generatePresignedUrl(String key, Duration expiry) { + Duration signatureDuration = expiry != null ? expiry : properties.getPresignExpiry(); PresignedGetObjectRequest request = s3Presigner.presignGetObject( GetObjectPresignRequest.builder() - .signatureDuration(expiry) + .signatureDuration(signatureDuration) .getObjectRequest(GetObjectRequest.builder() .bucket(properties.getBucket()) .key(key) diff --git a/web/Dockerfile b/web/Dockerfile index b5ea803f..d33dd1e6 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -8,7 +8,10 @@ RUN pnpm build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY nginx.conf.template /etc/nginx/templates/default.conf.template +COPY runtime-config.js.template /usr/share/nginx/html/runtime-config.js.template +COPY docker-entrypoint.d/30-runtime-config.sh /docker-entrypoint.d/30-runtime-config.sh +RUN chmod +x /docker-entrypoint.d/30-runtime-config.sh EXPOSE 80 HEALTHCHECK --interval=10s --timeout=3s \ CMD wget -qO- http://localhost/nginx-health || exit 1 diff --git a/web/docker-entrypoint.d/30-runtime-config.sh b/web/docker-entrypoint.d/30-runtime-config.sh new file mode 100644 index 00000000..f7ed8872 --- /dev/null +++ b/web/docker-entrypoint.d/30-runtime-config.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +: "${SKILLHUB_WEB_API_BASE_URL:=}" +: "${SKILLHUB_PUBLIC_BASE_URL:=}" + +envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL}' \ + < /usr/share/nginx/html/runtime-config.js.template \ + > /usr/share/nginx/html/runtime-config.js diff --git a/web/index.html b/web/index.html index cd90385f..f5d992e5 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,6 @@
- + diff --git a/web/nginx.conf b/web/nginx.conf.template similarity index 67% rename from web/nginx.conf rename to web/nginx.conf.template index e08010f5..15751f0a 100644 --- a/web/nginx.conf +++ b/web/nginx.conf.template @@ -13,7 +13,7 @@ server { } location /api/ { - proxy_pass http://server:8080; + proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -21,19 +21,23 @@ server { } location /oauth2/ { - proxy_pass http://server:8080; + proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; } location /login/oauth2/ { - proxy_pass http://server:8080; + proxy_pass ${SKILLHUB_API_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; } location /.well-known/ { - proxy_pass http://server:8080; + proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; } location /assets/ { @@ -41,6 +45,11 @@ server { add_header Cache-Control "public, immutable"; } + location = /runtime-config.js { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + location /nginx-health { return 200 'ok'; add_header Content-Type text/plain; diff --git a/web/runtime-config.js.template b/web/runtime-config.js.template new file mode 100644 index 00000000..8eef1601 --- /dev/null +++ b/web/runtime-config.js.template @@ -0,0 +1,4 @@ +window.__SKILLHUB_RUNTIME_CONFIG__ = { + apiBaseUrl: "${SKILLHUB_WEB_API_BASE_URL}", + appBaseUrl: "${SKILLHUB_PUBLIC_BASE_URL}" +}; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2d90cd0e..94f549a6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -23,7 +23,29 @@ import { ApiError } from '@/shared/lib/api-error' export { ApiError } -const client = createClient({ baseUrl: '' }) +type RuntimeConfig = { + apiBaseUrl?: string + appBaseUrl?: string +} + +declare global { + interface Window { + __SKILLHUB_RUNTIME_CONFIG__?: RuntimeConfig + } +} + +function getRuntimeConfig(): RuntimeConfig { + if (typeof window === 'undefined') { + return {} + } + return window.__SKILLHUB_RUNTIME_CONFIG__ ?? {} +} + +function getApiBaseUrl(): string { + return getRuntimeConfig().apiBaseUrl ?? '' +} + +const client = createClient({ baseUrl: getApiBaseUrl() }) function getCsrfToken(): string | null { const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/) @@ -95,7 +117,7 @@ type ApiEnvelope = { export async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { let response: Response try { - response = await fetch(input, init) + response = await fetch(withBaseUrl(input), init) } catch { throw new ApiError('Network error', 0) } @@ -119,13 +141,25 @@ export async function fetchJson(input: RequestInfo | URL, init?: RequestInit) } export async function fetchText(input: RequestInfo | URL, init?: RequestInit): Promise { - const response = await fetch(input, init) + const response = await fetch(withBaseUrl(input), init) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return response.text() } +function withBaseUrl(input: RequestInfo | URL): RequestInfo | URL { + const baseUrl = getApiBaseUrl() + if (!baseUrl || typeof input !== 'string' || !input.startsWith('/')) { + return input + } + return new URL(input, ensureTrailingSlash(baseUrl)) +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith('/') ? value : `${value}/` +} + export async function getCurrentUser(): Promise { try { const user = await unwrap(client.GET('/api/v1/auth/me') as never) diff --git a/web/src/bootstrap.ts b/web/src/bootstrap.ts new file mode 100644 index 00000000..93170167 --- /dev/null +++ b/web/src/bootstrap.ts @@ -0,0 +1,20 @@ +async function loadRuntimeConfig() { + await new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = '/runtime-config.js' + script.async = false + script.onload = () => resolve() + script.onerror = () => reject(new Error('Failed to load runtime config')) + document.head.appendChild(script) + }) +} + +void (async () => { + try { + await loadRuntimeConfig() + } catch (error) { + console.error(error) + } + + await import('./main') +})()