Refine production compose runtime config

This commit is contained in:
vsxd 2026-03-13 16:02:13 +08:00 committed by Xudong Sun
parent 14d86c290a
commit 9ae9b93ca7
17 changed files with 308 additions and 83 deletions

View file

@ -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=

View file

@ -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

View file

@ -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:

View file

@ -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. 分布式环境要求

View file

@ -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 可观测性

View file

@ -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; }
}

View file

@ -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());
}
}

View file

@ -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}

View file

@ -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; }
}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -10,6 +10,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/bootstrap.ts"></script>
</body>
</html>

View file

@ -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;

View file

@ -0,0 +1,4 @@
window.__SKILLHUB_RUNTIME_CONFIG__ = {
apiBaseUrl: "${SKILLHUB_WEB_API_BASE_URL}",
appBaseUrl: "${SKILLHUB_PUBLIC_BASE_URL}"
};

View file

@ -23,7 +23,29 @@ import { ApiError } from '@/shared/lib/api-error'
export { ApiError }
const client = createClient<paths>({ 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<paths>({ baseUrl: getApiBaseUrl() })
function getCsrfToken(): string | null {
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
@ -95,7 +117,7 @@ type ApiEnvelope<T> = {
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
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<T>(input: RequestInfo | URL, init?: RequestInit)
}
export async function fetchText(input: RequestInfo | URL, init?: RequestInit): Promise<string> {
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<User | null> {
try {
const user = await unwrap<User>(client.GET('/api/v1/auth/me') as never)

20
web/src/bootstrap.ts Normal file
View file

@ -0,0 +1,20 @@
async function loadRuntimeConfig() {
await new Promise<void>((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')
})()