diff --git a/.DS_Store b/.DS_Store index cae0d5c..8d07aef 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdf65d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# --- secrets / env(不要 commit 實值)--- +infra/.env +infra/etc/haixun.env +/opt/haixun/ + +# --- build 產物 --- +backend/bin/ +frontend/dist/ + +# --- 依賴 --- +node_modules/ + +# --- 其他 --- +*.log +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..29659b5 --- /dev/null +++ b/Makefile @@ -0,0 +1,174 @@ +# 巡樓 monorepo Makefile +# 兩種模式: +# dev - 本機開發(docker 起 Mongo/Redis,go run / vite dev) +# prod - 建置產物(前端 dist + linux Go binary),並可部署成 systemd 服務 + nginx +# +# 常用: +# make dev-infra # 起本機 Mongo/Redis +# make dev-backend # 跑 gateway (:8890) +# make dev-frontend # 跑前端 (:5173,proxy 到 :8890) +# make build # 產出 frontend/dist + backend/bin/{gateway,worker} +# sudo make install # 部署到目標主機(見 infra/README.md) + +SHELL := /bin/bash + +# --- 路徑 --- +BACKEND_DIR := backend +FRONTEND_DIR := frontend +INFRA_DIR := infra +BIN_DIR := $(BACKEND_DIR)/bin + +# --- 部署目標(install 用)--- +DEPLOY_ROOT := /opt/haixun +WEB_ROOT := /var/www/haixun + +# --- 交叉編譯目標(在 mac 上 build 給 linux 主機)--- +GOOS ?= linux +GOARCH ?= amd64 + +# --- docker compose --- +COMPOSE := docker compose -f $(INFRA_DIR)/docker-compose.yml --env-file $(INFRA_DIR)/.env + +# --- dev 用 worker secret(對應 etc/gateway.yaml 的 InternalWorker.Secret)--- +DEV_WORKER_SECRET := haixun-dev-worker-secret +DEV_BACKEND_URL := http://127.0.0.1:8890 + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## 顯示可用指令 + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# ============================================================ +# DEV +# ============================================================ + +$(INFRA_DIR)/.env: + @test -f $(INFRA_DIR)/.env || (cp $(INFRA_DIR)/.env.example $(INFRA_DIR)/.env && echo "已從 .env.example 建立 $(INFRA_DIR)/.env,請視需要修改密碼") + +.PHONY: dev-infra +dev-infra: $(INFRA_DIR)/.env ## [dev] 起本機 Mongo + Redis (docker) + $(COMPOSE) up -d + $(COMPOSE) ps + +.PHONY: dev-infra-down +dev-infra-down: ## [dev] 停掉本機 Mongo + Redis + $(COMPOSE) down + +.PHONY: dev-backend +dev-backend: ## [dev] 跑 gateway API (:8890) + cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml + +.PHONY: dev-worker +dev-worker: ## [dev] 跑 Go job worker (:8891) + cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml + +.PHONY: dev-node-worker +dev-node-worker: ## [dev] 跑 Node playwright worker (style-8d) + cd $(BACKEND_DIR)/worker && npm install && \ + HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d + +.PHONY: dev-frontend +dev-frontend: ## [dev] 跑前端 dev server (:5173) + cd $(FRONTEND_DIR) && npm install && npm run dev + +.PHONY: dev-init +dev-init: ## [dev] 初始化 DB(索引/權限)並建立 admin 帳號(可用 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD 覆寫) + cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml + +.PHONY: dev +dev: ## [dev] 顯示本機開發要開的終端 + @echo "本機開發請分開幾個終端執行:" + @echo " 1) make dev-infra # Mongo/Redis" + @echo " 2) make dev-backend # gateway :8890" + @echo " 3) make dev-worker # go worker(可選)" + @echo " 4) make dev-node-worker # node worker(需 style-8d 時)" + @echo " 5) make dev-frontend # 前端 :5173" + +# ============================================================ +# BUILD (prod 產物) +# ============================================================ + +.PHONY: build +build: build-frontend build-backend ## [prod] 建置前端 dist 與 Go binary + +.PHONY: build-frontend +build-frontend: ## [prod] 前端靜態建置 (tsc + vite) -> frontend/dist + cd $(FRONTEND_DIR) && npm ci && npm run build + +.PHONY: build-backend +build-backend: ## [prod] 交叉編譯 gateway + worker -> backend/bin (linux) + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/gateway . + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/worker ./cmd/worker + cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "-s -w" -o bin/tool ./cmd/tool + @echo "binary 已輸出到 $(BIN_DIR)/(gateway / worker / tool)" + +# ============================================================ +# PROD (部署) +# ============================================================ + +.PHONY: prod-infra +prod-infra: $(INFRA_DIR)/.env ## [prod] 起 Mongo + Redis (docker,背景) + $(COMPOSE) up -d + +.PHONY: prod-infra-down +prod-infra-down: ## [prod] 停掉 Mongo + Redis + $(COMPOSE) down + +.PHONY: prod-init +prod-init: ## [prod] 目標主機初始化 DB + 建立 admin(讀 /opt/haixun/etc/haixun.env;可加 INIT_ADMIN_EMAIL/PASSWORD) + @test -x $(DEPLOY_ROOT)/bin/tool || (echo "缺少 $(DEPLOY_ROOT)/bin/tool,請先 make install" && exit 1) + set -a; . $(DEPLOY_ROOT)/etc/haixun.env; set +a; \ + $(DEPLOY_ROOT)/bin/tool init -f $(DEPLOY_ROOT)/etc/gateway.prod.yaml + +.PHONY: install +install: ## [prod] 安裝 binary/前端/設定/systemd/nginx(需 root,在目標主機執行) + @test -f $(BIN_DIR)/gateway || (echo "缺少 $(BIN_DIR)/gateway,請先在能 build 的機器執行 make build" && exit 1) + @test -d $(FRONTEND_DIR)/dist || (echo "缺少 $(FRONTEND_DIR)/dist,請先 make build-frontend" && exit 1) + id haixun >/dev/null 2>&1 || useradd --system --home $(DEPLOY_ROOT) --shell /usr/sbin/nologin haixun + install -d $(DEPLOY_ROOT)/bin $(DEPLOY_ROOT)/etc $(DEPLOY_ROOT)/node-worker $(WEB_ROOT) + install -m 0755 $(BIN_DIR)/gateway $(DEPLOY_ROOT)/bin/gateway + install -m 0755 $(BIN_DIR)/worker $(DEPLOY_ROOT)/bin/worker + install -m 0755 $(BIN_DIR)/tool $(DEPLOY_ROOT)/bin/tool + install -m 0644 $(BACKEND_DIR)/etc/gateway.prod.yaml $(DEPLOY_ROOT)/etc/gateway.prod.yaml + install -m 0644 $(BACKEND_DIR)/etc/gateway.worker.prod.yaml $(DEPLOY_ROOT)/etc/gateway.worker.prod.yaml + rm -rf $(WEB_ROOT)/* && cp -r $(FRONTEND_DIR)/dist/* $(WEB_ROOT)/ + cp -r $(BACKEND_DIR)/worker/* $(DEPLOY_ROOT)/node-worker/ + cd $(DEPLOY_ROOT)/node-worker && npm ci && npx playwright install --with-deps chromium + install -m 0644 $(INFRA_DIR)/systemd/haixun-gateway.service /etc/systemd/system/haixun-gateway.service + install -m 0644 $(INFRA_DIR)/systemd/haixun-worker.service /etc/systemd/system/haixun-worker.service + install -m 0644 $(INFRA_DIR)/systemd/haixun-node-worker.service /etc/systemd/system/haixun-node-worker.service + install -m 0644 $(INFRA_DIR)/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf + chown -R haixun:haixun $(DEPLOY_ROOT) $(WEB_ROOT) + @echo "----" + @echo "接著(只做一次)建立 secret 檔:" + @echo " cp $(INFRA_DIR)/etc/haixun.env.example $(DEPLOY_ROOT)/etc/haixun.env && chmod 600 $(DEPLOY_ROOT)/etc/haixun.env && sudoedit $(DEPLOY_ROOT)/etc/haixun.env" + @echo "再啟用服務:" + @echo " systemctl daemon-reload && systemctl enable --now haixun-gateway haixun-worker haixun-node-worker" + @echo " nginx -t && systemctl reload nginx" + +# ============================================================ +# 驗證 / 維護 +# ============================================================ + +.PHONY: tidy +tidy: ## go mod tidy + cd $(BACKEND_DIR) && go mod tidy + +.PHONY: fmt +fmt: ## gofmt 後端 + cd $(BACKEND_DIR) && gofmt -w . + +.PHONY: test +test: ## 跑後端測試 + cd $(BACKEND_DIR) && go test ./... + +.PHONY: verify +verify: ## 後端 build/test + 前端 build + compose 語法 + cd $(BACKEND_DIR) && go build ./... && go test ./... + cd $(FRONTEND_DIR) && npm ci && npm run build + $(COMPOSE) config >/dev/null && echo "docker compose config OK" diff --git a/backend/.DS_Store b/backend/.DS_Store index 4380834..3397521 100644 Binary files a/backend/.DS_Store and b/backend/.DS_Store differ diff --git a/backend/cmd/tool/main.go b/backend/cmd/tool/main.go index cb936de..837a0dc 100644 --- a/backend/cmd/tool/main.go +++ b/backend/cmd/tool/main.go @@ -36,15 +36,15 @@ func runInit(args []string) error { fs := flag.NewFlagSet("init", flag.ExitOnError) configFile := fs.String("f", "etc/gateway.yaml", "config file") tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions") - email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@haixun.local"), "bootstrap admin email") - password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Admin-Pass-1!"), "bootstrap admin password") + email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@30cm.net"), "bootstrap admin email") + password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Fafafa54088"), "bootstrap admin password") displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name") if err := fs.Parse(args); err != nil { return err } var cfg config.Config - conf.MustLoad(*configFile, &cfg) + conf.MustLoad(*configFile, &cfg, conf.UseEnv()) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() diff --git a/backend/cmd/worker/main.go b/backend/cmd/worker/main.go index d669a47..e80b632 100644 --- a/backend/cmd/worker/main.go +++ b/backend/cmd/worker/main.go @@ -20,7 +20,7 @@ func main() { flag.Parse() var c config.Config - conf.MustLoad(*configFile, &c) + conf.MustLoad(*configFile, &c, conf.UseEnv()) if !c.JobWorker.Enabled { fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true") os.Exit(1) @@ -40,4 +40,4 @@ func main() { signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) <-ch fmt.Println("[worker] shutting down") -} \ No newline at end of file +} diff --git a/backend/etc/gateway.prod.yaml b/backend/etc/gateway.prod.yaml index d833f4d..e6ca1e3 100644 --- a/backend/etc/gateway.prod.yaml +++ b/backend/etc/gateway.prod.yaml @@ -3,24 +3,30 @@ Host: 0.0.0.0 Port: 8890 Timeout: 120000 +# 連線字串與所有 secret 都從環境變數注入(systemd EnvironmentFile=/opt/haixun/etc/haixun.env)。 +# go-zero 以 conf.UseEnv() + os.ExpandEnv 展開 ${VAR};未設定的變數會展開為空字串並讓服務 fail fast。 Mongo: - URI: mongodb://mongo:27017 - Database: haixun + URI: ${HAIXUN_MONGO_URI} + Database: ${HAIXUN_MONGO_DB} TimeoutSeconds: 10 Redis: - Addr: redis:6379 + Addr: ${HAIXUN_REDIS_ADDR} + Password: ${HAIXUN_REDIS_PASSWORD} DB: 0 Auth: - AccessSecret: change-me-in-prod - RefreshSecret: change-me-in-prod-too + AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET} + RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET} AccessExpireSeconds: 900 RefreshExpireSeconds: 2592000 DevHeaderFallback: false +Secrets: + EncryptionKey: ${HAIXUN_SECRETS_KEY} + InternalWorker: - Secret: change-me-worker-secret + Secret: ${HAIXUN_WORKER_SECRET} JobWorker: Enabled: false @@ -32,4 +38,4 @@ JobScheduler: JobReaper: Enabled: true - IntervalSeconds: 30 \ No newline at end of file + IntervalSeconds: 30 diff --git a/backend/etc/gateway.worker.prod.yaml b/backend/etc/gateway.worker.prod.yaml new file mode 100644 index 0000000..9491084 --- /dev/null +++ b/backend/etc/gateway.worker.prod.yaml @@ -0,0 +1,39 @@ +Name: haixun-worker +Host: 0.0.0.0 +Port: 8891 +Timeout: 120000 + +Mongo: + URI: ${HAIXUN_MONGO_URI} + Database: ${HAIXUN_MONGO_DB} + TimeoutSeconds: 10 + +Redis: + Addr: ${HAIXUN_REDIS_ADDR} + Password: ${HAIXUN_REDIS_PASSWORD} + DB: 0 + +Auth: + AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET} + RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET} + AccessExpireSeconds: 900 + RefreshExpireSeconds: 2592000 + DevHeaderFallback: false + +Secrets: + EncryptionKey: ${HAIXUN_SECRETS_KEY} + +InternalWorker: + Secret: ${HAIXUN_WORKER_SECRET} + +JobWorker: + Enabled: true + WorkerType: go + +JobScheduler: + Enabled: false + IntervalSeconds: 60 + +JobReaper: + Enabled: false + IntervalSeconds: 30 diff --git a/backend/etc/gateway.worker.yaml b/backend/etc/gateway.worker.yaml index ca97498..05dfaba 100644 --- a/backend/etc/gateway.worker.yaml +++ b/backend/etc/gateway.worker.yaml @@ -3,24 +3,29 @@ Host: 0.0.0.0 Port: 8891 Timeout: 120000 +# 本機開發 worker 設定(go worker)。搭配 `make dev-infra` 的 Mongo/Redis。 Mongo: - URI: mongodb://mongo:27017 + URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin Database: haixun TimeoutSeconds: 10 Redis: - Addr: redis:6379 + Addr: 127.0.0.1:6379 + Password: change-me-redis-pass DB: 0 Auth: - AccessSecret: change-me-in-prod - RefreshSecret: change-me-in-prod-too + AccessSecret: haixun-dev-access-secret-change-me + RefreshSecret: haixun-dev-refresh-secret-change-me AccessExpireSeconds: 900 RefreshExpireSeconds: 2592000 DevHeaderFallback: false +Secrets: + EncryptionKey: "" + InternalWorker: - Secret: change-me-worker-secret + Secret: haixun-dev-worker-secret JobWorker: Enabled: true @@ -32,4 +37,4 @@ JobScheduler: JobReaper: Enabled: false - IntervalSeconds: 30 \ No newline at end of file + IntervalSeconds: 30 diff --git a/backend/etc/gateway.yaml b/backend/etc/gateway.yaml index eeaffea..399b90b 100644 --- a/backend/etc/gateway.yaml +++ b/backend/etc/gateway.yaml @@ -3,13 +3,16 @@ Host: 0.0.0.0 Port: 8890 Timeout: 120000 +# 本機開發設定。預設搭配 `make dev-infra`(infra/docker-compose.yml)跑的 Mongo/Redis, +# 帳密對應 infra/.env.example 的預設值。若你改了 .env 密碼,這裡也要同步。 Mongo: - URI: mongodb://127.0.0.1:27017 + URI: mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin Database: haixun TimeoutSeconds: 10 Redis: Addr: 127.0.0.1:6379 + Password: change-me-redis-pass DB: 0 Auth: @@ -17,8 +20,16 @@ Auth: RefreshSecret: haixun-dev-refresh-secret-change-me AccessExpireSeconds: 900 RefreshExpireSeconds: 2592000 + # 僅本機開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須為 false。 DevHeaderFallback: true +Secrets: + # 留空 = 不加密(本機開發方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。 + EncryptionKey: "" + +InternalWorker: + Secret: haixun-dev-worker-secret + JobWorker: Enabled: true WorkerType: go diff --git a/backend/gateway.go b/backend/gateway.go index ab4a148..78a8bd2 100644 --- a/backend/gateway.go +++ b/backend/gateway.go @@ -19,7 +19,7 @@ func main() { flag.Parse() var c config.Config - conf.MustLoad(*configFile, &c) + conf.MustLoad(*configFile, &c, conf.UseEnv()) server := rest.MustNewServer(c.RestConf) defer server.Stop() diff --git a/backend/internal/bootstrap/init.go b/backend/internal/bootstrap/init.go index 6706fbd..5b00b39 100644 --- a/backend/internal/bootstrap/init.go +++ b/backend/internal/bootstrap/init.go @@ -5,12 +5,23 @@ import ( "fmt" "haixun-backend/internal/config" + libcrypto "haixun-backend/internal/library/crypto" libmongo "haixun-backend/internal/library/mongo" + brandrepo "haixun-backend/internal/model/brand/repository" + cmatrixrepo "haixun-backend/internal/model/content_matrix/repository" + copydraftrepo "haixun-backend/internal/model/copy_draft/repository" + copymissionrepo "haixun-backend/internal/model/copy_mission/repository" jobrepo "haixun-backend/internal/model/job/repository" + kgrepo "haixun-backend/internal/model/knowledge_graph/repository" memberrepo "haixun-backend/internal/model/member/repository" + outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository" permissionrepo "haixun-backend/internal/model/permission/repository" permissionuc "haixun-backend/internal/model/permission/usecase" + personarepo "haixun-backend/internal/model/persona/repository" + placementtopicrepo "haixun-backend/internal/model/placement_topic/repository" + scanpostrepo "haixun-backend/internal/model/scan_post/repository" settingrepo "haixun-backend/internal/model/setting/repository" + threadsaccountrepo "haixun-backend/internal/model/threads_account/repository" ) type InitOptions struct { @@ -48,6 +59,12 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport db := mongoClient.Database() report := &InitReport{} + // cipher 只用於資料加解密;EnsureIndexes 不會用到,但 secrets repo 建構子需要它。 + secretsCipher, err := libcrypto.New(cfg.Secrets.EncryptionKey) + if err != nil { + return nil, fmt.Errorf("init secrets cipher: %w", err) + } + settingRepository := settingrepo.NewMongoRepository(db) memberRepository := memberrepo.NewMongoRepository(db) permissionRepository := permissionrepo.NewMongoPermissionRepository(db) @@ -69,6 +86,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport {"job_runs", jobRunRepository.EnsureIndexes}, {"job_schedules", jobScheduleRepository.EnsureIndexes}, {"job_events", jobEventRepository.EnsureIndexes}, + {"copy_missions", copymissionrepo.NewMongoRepository(db).EnsureIndexes}, + {"copy_drafts", copydraftrepo.NewMongoRepository(db).EnsureIndexes}, + {"scan_posts", scanpostrepo.NewMongoRepository(db).EnsureIndexes}, + {"outreach_drafts", outreachdraftrepo.NewMongoRepository(db).EnsureIndexes}, + {"content_matrix", cmatrixrepo.NewMongoRepository(db).EnsureIndexes}, + {"knowledge_graph", kgrepo.NewMongoRepository(db).EnsureIndexes}, + {"personas", personarepo.NewMongoRepository(db).EnsureIndexes}, + {"brands", brandrepo.NewMongoRepository(db).EnsureIndexes}, + {"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes}, + {"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes}, + {"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes}, } for _, repo := range repos { if err := repo.fn(ctx); err != nil { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9b22f72..478ea7a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,8 +9,9 @@ type MongoConf struct { } type RedisConf struct { - Addr string `json:",optional"` - DB int `json:",optional"` + Addr string `json:",optional"` + Password string `json:",optional"` + DB int `json:",optional"` } type JobWorkerConf struct { @@ -34,13 +35,19 @@ type AuthConf struct { RefreshSecret string `json:",optional"` AccessExpireSeconds int64 `json:",default=900"` RefreshExpireSeconds int64 `json:",default=2592000"` - DevHeaderFallback bool `json:",default=true"` + DevHeaderFallback bool `json:",default=false"` } type InternalWorkerConf struct { Secret string `json:",optional"` } +// SecretsConf holds the application-layer encryption key (base64 of 32 bytes) +// used to encrypt sensitive data at rest (browser session, third-party API keys). +type SecretsConf struct { + EncryptionKey string `json:",optional"` +} + type BraveConf struct { APIKey string `json:",optional"` } @@ -50,6 +57,7 @@ type Config struct { Mongo MongoConf `json:",optional"` Redis RedisConf `json:",optional"` Auth AuthConf `json:",optional"` + Secrets SecretsConf `json:",optional"` InternalWorker InternalWorkerConf `json:",optional"` JobWorker JobWorkerConf `json:",optional"` JobScheduler JobSchedulerConf `json:",optional"` diff --git a/backend/internal/library/crypto/aesgcm.go b/backend/internal/library/crypto/aesgcm.go new file mode 100644 index 0000000..d802417 --- /dev/null +++ b/backend/internal/library/crypto/aesgcm.go @@ -0,0 +1,99 @@ +// Package crypto provides application-layer encryption for sensitive data at +// rest (browser session storage, third-party API keys). +// +// Ciphertext format: "enc:v1:" + base64url(nonce || ciphertext||tag). +// Values without the prefix are treated as legacy plaintext and returned as-is +// on Decrypt, so enabling encryption is backward compatible with existing data. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" +) + +const cipherPrefix = "enc:v1:" + +// Cipher encrypts/decrypts secret strings. When built without a key it is +// disabled and acts as a passthrough (for local dev), but Decrypt still +// transparently handles previously written ciphertext if any. +type Cipher struct { + aead cipher.AEAD +} + +// New builds a Cipher from a base64-encoded 32-byte (AES-256) key. +// An empty key returns a disabled (passthrough) cipher. +func New(base64Key string) (*Cipher, error) { + base64Key = strings.TrimSpace(base64Key) + if base64Key == "" { + return &Cipher{}, nil + } + key, err := base64.StdEncoding.DecodeString(base64Key) + if err != nil { + return nil, fmt.Errorf("crypto: invalid base64 encryption key: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf("crypto: encryption key must be 32 bytes (got %d)", len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("crypto: new cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("crypto: new gcm: %w", err) + } + return &Cipher{aead: aead}, nil +} + +// Enabled reports whether a key is configured. +func (c *Cipher) Enabled() bool { + return c != nil && c.aead != nil +} + +// Encrypt returns ciphertext for non-empty plaintext when enabled; otherwise +// it returns the input unchanged. +func (c *Cipher) Encrypt(plaintext string) (string, error) { + if !c.Enabled() || plaintext == "" { + return plaintext, nil + } + if strings.HasPrefix(plaintext, cipherPrefix) { + // Already encrypted; avoid double-encrypting. + return plaintext, nil + } + nonce := make([]byte, c.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("crypto: read nonce: %w", err) + } + sealed := c.aead.Seal(nonce, nonce, []byte(plaintext), nil) + return cipherPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil +} + +// Decrypt reverses Encrypt. Values without the cipher prefix are returned +// unchanged (legacy plaintext), keeping the change backward compatible. +func (c *Cipher) Decrypt(value string) (string, error) { + if !strings.HasPrefix(value, cipherPrefix) { + return value, nil + } + if !c.Enabled() { + return "", errors.New("crypto: encrypted value found but no encryption key configured") + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, cipherPrefix)) + if err != nil { + return "", fmt.Errorf("crypto: decode ciphertext: %w", err) + } + nonceSize := c.aead.NonceSize() + if len(raw) < nonceSize { + return "", errors.New("crypto: ciphertext too short") + } + nonce, ct := raw[:nonceSize], raw[nonceSize:] + plain, err := c.aead.Open(nil, nonce, ct, nil) + if err != nil { + return "", fmt.Errorf("crypto: decrypt: %w", err) + } + return string(plain), nil +} diff --git a/backend/internal/library/redis/client.go b/backend/internal/library/redis/client.go index ff13bcd..6008893 100644 --- a/backend/internal/library/redis/client.go +++ b/backend/internal/library/redis/client.go @@ -11,7 +11,8 @@ func NewClient(conf config.RedisConf) *goredis.Client { return nil } return goredis.NewClient(&goredis.Options{ - Addr: conf.Addr, - DB: conf.DB, + Addr: conf.Addr, + Password: conf.Password, + DB: conf.DB, }) } diff --git a/backend/internal/logic/setting/authz.go b/backend/internal/logic/setting/authz.go new file mode 100644 index 0000000..471e440 --- /dev/null +++ b/backend/internal/logic/setting/authz.go @@ -0,0 +1,41 @@ +package setting + +import ( + "context" + + "haixun-backend/internal/library/authctx" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/svc" +) + +// authorizeSettingAccess guards the generic settings API against horizontal +// privilege escalation (IDOR). The settings collection stores sensitive values +// such as AI / Brave / Exa API keys under the "user" and "account" scopes, so +// the caller must be the owner of the scope being accessed. +// +// - "user": scope_id must equal the caller uid (uid is enumerable). +// - "account": the threads account must belong to the caller. +// - other scopes (brand / persona / placement_topic / copy_mission ...): +// keyed by unguessable ObjectIDs and hold non-secret research config; +// ownership is enforced by their own feature endpoints. We still require an +// authenticated actor here. +func authorizeSettingAccess(ctx context.Context, svcCtx *svc.ServiceContext, scope, scopeID string) error { + actor, ok := authctx.ActorFromContext(ctx) + if !ok || actor.UID == "" { + return app.For(code.Auth).AuthUnauthorized("missing actor") + } + + switch scope { + case "user": + if scopeID != actor.UID { + return app.For(code.Setting).AuthForbidden("cannot access another user's settings") + } + case "account": + if _, err := svcCtx.ThreadsAccount.Get(ctx, actor.TenantID, actor.UID, scopeID); err != nil { + return app.For(code.Setting).AuthForbidden("cannot access settings of an account you do not own") + } + } + + return nil +} diff --git a/backend/internal/logic/setting/delete_setting_logic.go b/backend/internal/logic/setting/delete_setting_logic.go index 6eb1395..3113a69 100644 --- a/backend/internal/logic/setting/delete_setting_logic.go +++ b/backend/internal/logic/setting/delete_setting_logic.go @@ -17,5 +17,8 @@ func NewDeleteSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Del } func (l *DeleteSettingLogic) DeleteSetting(req *types.SettingKeyPath) error { + if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil { + return err + } return l.svcCtx.Setting.Delete(l.ctx, req.Scope, req.ScopeID, req.Key) } diff --git a/backend/internal/logic/setting/get_setting_logic.go b/backend/internal/logic/setting/get_setting_logic.go index eb5ab6d..3ea83d5 100644 --- a/backend/internal/logic/setting/get_setting_logic.go +++ b/backend/internal/logic/setting/get_setting_logic.go @@ -17,6 +17,9 @@ func NewGetSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSet } func (l *GetSettingLogic) GetSetting(req *types.SettingKeyPath) (*types.SettingData, error) { + if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil { + return nil, err + } item, err := l.svcCtx.Setting.Get(l.ctx, req.Scope, req.ScopeID, req.Key) if err != nil { return nil, err diff --git a/backend/internal/logic/setting/list_settings_logic.go b/backend/internal/logic/setting/list_settings_logic.go index da5b2cc..f63fdb8 100644 --- a/backend/internal/logic/setting/list_settings_logic.go +++ b/backend/internal/logic/setting/list_settings_logic.go @@ -17,6 +17,9 @@ func NewListSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *List } func (l *ListSettingsLogic) ListSettings(req *types.SettingPath) (*types.SettingListData, error) { + if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil { + return nil, err + } items, total, page, pageSize, err := l.svcCtx.Setting.List(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize) if err != nil { return nil, err diff --git a/backend/internal/logic/setting/upsert_setting_logic.go b/backend/internal/logic/setting/upsert_setting_logic.go index d30547d..c9a2cee 100644 --- a/backend/internal/logic/setting/upsert_setting_logic.go +++ b/backend/internal/logic/setting/upsert_setting_logic.go @@ -18,6 +18,9 @@ func NewUpsertSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ups } func (l *UpsertSettingLogic) UpsertSetting(req *types.SettingUpsertReq) (*types.SettingData, error) { + if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil { + return nil, err + } item, err := l.svcCtx.Setting.Upsert(l.ctx, settinguc.UpsertRequest{ Scope: req.Scope, ScopeID: req.ScopeID, diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go deleted file mode 100644 index 1ac202c..0000000 --- a/backend/internal/middleware/auth_middleware.go +++ /dev/null @@ -1,22 +0,0 @@ -// Code scaffolded by goctl. Safe to edit. -// goctl 1.10.1 - -package middleware - -import "net/http" - -type AuthMiddleware struct { -} - -func NewAuthMiddleware() *AuthMiddleware { - return &AuthMiddleware{} -} - -func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // TODO generate middleware implement function, delete after code implementation - - // Passthrough to next handler if need - next(w, r) - } -} diff --git a/backend/internal/middleware/permissionrbac_middleware.go b/backend/internal/middleware/permissionrbac_middleware.go index 565655a..870afbc 100644 --- a/backend/internal/middleware/permissionrbac_middleware.go +++ b/backend/internal/middleware/permissionrbac_middleware.go @@ -13,7 +13,14 @@ import ( ) // PermissionRBACMiddleware enforces catalog permissions for authenticated members. -// Mount after AuthJWT or MemberAuth so actor is present in context. +// It is composed AFTER AuthJWT / MemberAuth (see svc.NewServiceContext) so the +// actor is already in context. +// +// Route coverage: defaultPermissions uses trailing-* patterns +// (/api/v1/brands/*, /api/v1/personas/*, /api/v1/placement/topics/*, ...) that +// cover nested routes (copy-missions, scan-posts, knowledge-graph, etc.). +// permission.Me also falls back to the default user permission set when a tenant +// has no seeded role_permissions, so members are never locked out by a missing seed. type PermissionRBACMiddleware struct { members memberdomain.UseCase permissions permissiondomain.UseCase diff --git a/backend/internal/middleware/workersecret_middleware.go b/backend/internal/middleware/workersecret_middleware.go index ad59b61..02995f2 100644 --- a/backend/internal/middleware/workersecret_middleware.go +++ b/backend/internal/middleware/workersecret_middleware.go @@ -1,6 +1,7 @@ package middleware import ( + "crypto/subtle" "net/http" "strings" @@ -12,10 +13,10 @@ import ( const WorkerSecretHeader = "X-Worker-Secret" -// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes when -// InternalWorker.Secret is configured. Mounted via @server(middleware: WorkerSecret) -// in generate/api/worker_internal.api. When the secret is empty it passes through, -// preserving local-dev behaviour. +// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes. +// The secret is REQUIRED: when InternalWorker.Secret is empty the middleware +// rejects every request (fail closed) instead of passing through, so the +// internal worker endpoints can never be exposed unauthenticated. type WorkerSecretMiddleware struct { cfg config.InternalWorkerConf } @@ -27,7 +28,12 @@ func NewWorkerSecretMiddleware(cfg config.InternalWorkerConf) *WorkerSecretMiddl func (m *WorkerSecretMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { secret := strings.TrimSpace(m.cfg.Secret) - if secret != "" && r.Header.Get(WorkerSecretHeader) != secret { + if secret == "" { + response.Write(r.Context(), w, nil, app.For(code.Auth).AuthForbidden("internal worker secret is not configured")) + return + } + provided := r.Header.Get(WorkerSecretHeader) + if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { response.Write(r.Context(), w, nil, app.For(code.Auth).AuthUnauthorized("invalid worker secret")) return } diff --git a/backend/internal/model/auth/usecase/token.go b/backend/internal/model/auth/usecase/token.go index ca1e040..efb27e9 100644 --- a/backend/internal/model/auth/usecase/token.go +++ b/backend/internal/model/auth/usecase/token.go @@ -23,6 +23,12 @@ type tokenUseCase struct { func NewTokenUseCase(cfg config.AuthConf, revoke domrepo.TokenRevokeStore) domusecase.TokenUseCase { cfg = normalizeConfig(cfg) + if cfg.AccessSecret == "" || cfg.RefreshSecret == "" { + // Fail fast: never fall back to a known/hardcoded secret, otherwise tokens + // could be forged. Provide secrets via env (HAIXUN_JWT_ACCESS_SECRET / + // HAIXUN_JWT_REFRESH_SECRET) or the dev config file. + panic("auth: AccessSecret and RefreshSecret must be configured") + } return &tokenUseCase{cfg: cfg, revoke: revoke} } @@ -210,12 +216,6 @@ func normalizeConfig(cfg config.AuthConf) config.AuthConf { if cfg.RefreshExpireSeconds <= 0 { cfg.RefreshExpireSeconds = 2592000 } - if cfg.AccessSecret == "" { - cfg.AccessSecret = "haixun-dev-access-secret-change-me" - } - if cfg.RefreshSecret == "" { - cfg.RefreshSecret = "haixun-dev-refresh-secret-change-me" - } return cfg } diff --git a/backend/internal/model/permission/usecase/usecase_test.go b/backend/internal/model/permission/usecase/usecase_test.go index ca9e019..67c60cb 100644 --- a/backend/internal/model/permission/usecase/usecase_test.go +++ b/backend/internal/model/permission/usecase/usecase_test.go @@ -11,4 +11,4 @@ func TestMergeHTTPMethods(t *testing.T) { if got != "GET|POST|PATCH" { t.Fatalf("got %q", got) } -} \ No newline at end of file +} diff --git a/backend/internal/model/placement/usecase/settings.go b/backend/internal/model/placement/usecase/settings.go index ad730d7..1e7f423 100644 --- a/backend/internal/model/placement/usecase/settings.go +++ b/backend/internal/model/placement/usecase/settings.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "haixun-backend/internal/library/crypto" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libkg "haixun-backend/internal/library/knowledge" @@ -47,10 +48,11 @@ type UseCase interface { type placementUseCase struct { settings settingdomain.UseCase + cipher *crypto.Cipher } -func NewUseCase(settings settingdomain.UseCase) UseCase { - return &placementUseCase{settings: settings} +func NewUseCase(settings settingdomain.UseCase, cipher *crypto.Cipher) UseCase { + return &placementUseCase{settings: settings, cipher: cipher} } func (u *placementUseCase) Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error) { @@ -107,15 +109,49 @@ func (u *placementUseCase) load(ctx context.Context, ownerUID string) (storedSet } return defaults, err } - return mergeSettings(defaults, setting.Value), nil + merged := mergeSettings(defaults, setting.Value) + if u.cipher != nil { + if merged.BraveAPIKey, err = u.decryptKey(merged.BraveAPIKey); err != nil { + return defaults, err + } + if merged.ExaAPIKey, err = u.decryptKey(merged.ExaAPIKey); err != nil { + return defaults, err + } + } + return merged, nil +} + +func (u *placementUseCase) decryptKey(value string) (string, error) { + if value == "" { + return "", nil + } + plain, err := u.cipher.Decrypt(value) + if err != nil { + return "", err + } + return strings.TrimSpace(plain), nil } func (u *placementUseCase) save(ctx context.Context, ownerUID string, value storedSettings) error { + payload := value.toMap() + if u.cipher != nil { + for _, key := range []string{"brave_api_key", "exa_api_key"} { + s, ok := payload[key].(string) + if !ok || s == "" { + continue + } + enc, err := u.cipher.Encrypt(s) + if err != nil { + return err + } + payload[key] = enc + } + } _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{ Scope: settingScopeUser, ScopeID: ownerUID, Key: keyResearch, - Value: value.toMap(), + Value: payload, }) return err } diff --git a/backend/internal/model/threads_account/repository/secrets_mongo.go b/backend/internal/model/threads_account/repository/secrets_mongo.go index 3054eef..edc75e5 100644 --- a/backend/internal/model/threads_account/repository/secrets_mongo.go +++ b/backend/internal/model/threads_account/repository/secrets_mongo.go @@ -4,6 +4,7 @@ import ( "context" "haixun-backend/internal/library/clock" + "haixun-backend/internal/library/crypto" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/model/threads_account/domain/entity" @@ -16,13 +17,14 @@ import ( type secretsMongoRepository struct { collection *mongo.Collection + cipher *crypto.Cipher } -func NewSecretsMongoRepository(db *mongo.Database) domrepo.SecretsRepository { +func NewSecretsMongoRepository(db *mongo.Database, cipher *crypto.Cipher) domrepo.SecretsRepository { if db == nil { - return &secretsMongoRepository{} + return &secretsMongoRepository{cipher: cipher} } - return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName)} + return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName), cipher: cipher} } func (r *secretsMongoRepository) EnsureIndexes(ctx context.Context) error { @@ -47,6 +49,13 @@ func (r *secretsMongoRepository) FindByAccountID(ctx context.Context, accountID if err != nil { return nil, err } + if r.cipher != nil && out.BrowserStorageState != "" { + plain, decErr := r.cipher.Decrypt(out.BrowserStorageState) + if decErr != nil { + return nil, decErr + } + out.BrowserStorageState = plain + } return &out, nil } @@ -55,13 +64,21 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") } now := clock.NowUnixNano() + storedValue := storageState + if r.cipher != nil { + enc, encErr := r.cipher.Encrypt(storageState) + if encErr != nil { + return nil, encErr + } + storedValue = enc + } var out entity.Secrets err := r.collection.FindOneAndUpdate( ctx, bson.M{"_id": accountID}, bson.M{ "$set": bson.M{ - "browser_storage_state": storageState, + "browser_storage_state": storedValue, "update_at": now, }, "$setOnInsert": bson.M{"_id": accountID}, @@ -71,5 +88,7 @@ func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, ac if err != nil { return nil, err } + // Return plaintext to callers regardless of at-rest encryption. + out.BrowserStorageState = storageState return &out, nil } diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go index 9016317..19fbc1c 100644 --- a/backend/internal/model/threads_account/usecase/ai_credentials.go +++ b/backend/internal/model/threads_account/usecase/ai_credentials.go @@ -129,7 +129,7 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, defaults := defaultAiCredentials() setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials) if err == nil { - return mergeAiCredentials(defaults, setting.Value), nil + return u.mergeAiCredentials(defaults, setting.Value) } if !isSettingNotFound(err) { return defaults, err @@ -142,7 +142,10 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, } return defaults, err } - creds := mergeAiCredentials(defaults, legacy.Value) + creds, err := u.mergeAiCredentials(defaults, legacy.Value) + if err != nil { + return defaults, err + } if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil { return creds, saveErr } @@ -150,18 +153,24 @@ func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, } func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error { - _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{ + value, err := u.aiCredentialsToMap(creds) + if err != nil { + return err + } + _, err = u.settings.Upsert(ctx, settingdomain.UpsertRequest{ Scope: settingScopeUser, ScopeID: ownerUID, Key: keyAiCredentials, - Value: aiCredentialsToMap(creds), + Value: value, }) return err } -func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) aiCredentials { +// mergeAiCredentials merges stored setting values onto defaults, decrypting any +// at-rest encrypted API keys. +func (u *threadsAccountUseCase) mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) (aiCredentials, error) { if value == nil { - return defaults + return defaults, nil } if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" { defaults.Provider = v @@ -178,13 +187,23 @@ func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) ai if raw, ok := value["api_keys"].(map[string]interface{}); ok { keys := map[string]string{} for provider, item := range raw { - if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { - keys[provider] = strings.TrimSpace(s) + s, ok := item.(string) + if !ok || strings.TrimSpace(s) == "" { + continue } + plain := strings.TrimSpace(s) + if u.cipher != nil { + decrypted, err := u.cipher.Decrypt(plain) + if err != nil { + return defaults, err + } + plain = strings.TrimSpace(decrypted) + } + keys[provider] = plain } defaults.ApiKeys = keys } - return defaults + return defaults, nil } func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials { @@ -215,10 +234,18 @@ func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatc return current } -func aiCredentialsToMap(creds aiCredentials) map[string]interface{} { +func (u *threadsAccountUseCase) aiCredentialsToMap(creds aiCredentials) (map[string]interface{}, error) { keys := map[string]interface{}{} for provider, value := range creds.ApiKeys { - keys[provider] = value + stored := value + if u.cipher != nil { + enc, err := u.cipher.Encrypt(value) + if err != nil { + return nil, err + } + stored = enc + } + keys[provider] = stored } return map[string]interface{}{ "provider": creds.Provider, @@ -226,7 +253,7 @@ func aiCredentialsToMap(creds aiCredentials) map[string]interface{} { "research_provider": creds.ResearchProvider, "research_model": creds.ResearchModel, "api_keys": keys, - } + }, nil } func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings { diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index d78c8e7..45a6ab0 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -7,6 +7,7 @@ import ( "strings" "haixun-backend/internal/library/clock" + "haixun-backend/internal/library/crypto" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/placement" @@ -34,6 +35,7 @@ type threadsAccountUseCase struct { members memberdomain.UseCase settings settingdomain.UseCase personas personadomain.UseCase + cipher *crypto.Cipher } func NewUseCase( @@ -42,6 +44,7 @@ func NewUseCase( members memberdomain.UseCase, settings settingdomain.UseCase, personas personadomain.UseCase, + cipher *crypto.Cipher, ) domusecase.UseCase { return &threadsAccountUseCase{ repo: repo, @@ -49,6 +52,7 @@ func NewUseCase( members: members, settings: settings, personas: personas, + cipher: cipher, } } diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index b63e021..f9311de 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -3,10 +3,12 @@ package svc import ( "context" "fmt" + "net/http" "os" "time" "haixun-backend/internal/config" + libcrypto "haixun-backend/internal/library/crypto" libmongo "haixun-backend/internal/library/mongo" libredis "haixun-backend/internal/library/redis" "haixun-backend/internal/library/validate" @@ -105,6 +107,11 @@ func NewServiceContext(c config.Config) *ServiceContext { } redisClient := libredis.NewClient(c.Redis) + secretsCipher, err := libcrypto.New(c.Secrets.EncryptionKey) + if err != nil { + panic(err) + } + settingRepository := settingrepo.NewMongoRepository(mongoClient.Database()) if err := settingRepository.EnsureIndexes(ctx); err != nil { panic(err) @@ -241,7 +248,7 @@ func NewServiceContext(c config.Config) *ServiceContext { scanPostRepository, ) threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database()) - threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database()) + threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher) if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil { panic(err) } @@ -254,9 +261,10 @@ func NewServiceContext(c config.Config) *ServiceContext { memberUseCase, settingUseCase, personaUseCase, + secretsCipher, ) - placementUseCase := placementusecase.NewUseCase(settingUseCase) + placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher) sc := &ServiceContext{ Config: c, @@ -375,8 +383,14 @@ func NewServiceContext(c config.Config) *ServiceContext { go reaper.Start(reaperCtx, interval) } - sc.AuthJWT = middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle - sc.MemberAuth = middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle + // 認證 + RBAC:先驗 JWT(把 actor 放進 context),再用 catalog 權限做路由級授權。 + // 兩者組合後掛在受保護的 route group 上(routes.go 以 AuthJWT / MemberAuth 引用)。 + authJWT := middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle + memberAuth := middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle + rbac := middleware.NewPermissionRBACMiddleware(sc.Member, sc.Permission).Handle + + sc.AuthJWT = func(next http.HandlerFunc) http.HandlerFunc { return authJWT(rbac(next)) } + sc.MemberAuth = func(next http.HandlerFunc) http.HandlerFunc { return memberAuth(rbac(next)) } sc.WorkerSecret = middleware.NewWorkerSecretMiddleware(sc.Config.InternalWorker).Handle return sc diff --git a/backend/internal/worker/job/runner.go b/backend/internal/worker/job/runner.go index b9e7c6f..e68d554 100644 --- a/backend/internal/worker/job/runner.go +++ b/backend/internal/worker/job/runner.go @@ -111,14 +111,16 @@ func (r *Runner) execute(ctx context.Context, run *entity.Run) { } } percentage := (completedBefore * 100) / totalSteps - _, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{ + if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{ JobID: jobID, WorkerID: r.workerID, Phase: step.ID, Summary: "running step " + step.ID, Percentage: percentage, Steps: steps, - }) + }); err != nil { + log.Printf("job worker update progress (running) failed: job=%s step=%s err=%v", jobID, step.ID, err) + } if err := r.runStep(ctx, run, template, step); err != nil { if err == errJobCancelled { @@ -155,28 +157,32 @@ func (r *Runner) execute(ctx context.Context, run *entity.Run) { } completedBefore++ percentage = (completedBefore * 100) / totalSteps - _, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{ + if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{ JobID: jobID, WorkerID: r.workerID, Phase: step.ID, Summary: "completed step " + step.ID, Percentage: percentage, Steps: steps, - }) + }); err != nil { + log.Printf("job worker update progress (completed step) failed: job=%s step=%s err=%v", jobID, step.ID, err) + } } fresh, err := r.jobs.GetRun(ctx, jobID) if err == nil && fresh != nil && fresh.Status.IsTerminal() { return } - _, _ = r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{ + if _, err := r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{ JobID: jobID, WorkerID: r.workerID, Result: map[string]any{ - "message": "demo long task completed", - "steps": len(template.Steps), + "template": run.TemplateType, + "steps": len(template.Steps), }, - }) + }); err != nil { + log.Printf("job worker complete run failed: job=%s err=%v", jobID, err) + } } var errJobCancelled = errors.New("job cancelled") diff --git a/backend/worker/package-lock.json b/backend/worker/package-lock.json new file mode 100644 index 0000000..da2729d --- /dev/null +++ b/backend/worker/package-lock.json @@ -0,0 +1,593 @@ +{ + "name": "haixun-node-worker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "haixun-node-worker", + "dependencies": { + "playwright": "^1.49.1" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 737ed96..f897e37 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { AuthProvider } from './auth/AuthContext' import { ThemeProvider } from './theme/ThemeContext' +import { ErrorBoundary } from './components/ErrorBoundary' import { AdminRoute } from './components/AdminRoute' import { Layout } from './components/Layout' import { ProtectedRoute } from './components/ProtectedRoute' @@ -37,6 +38,7 @@ export default function App() { + } /> } /> @@ -78,6 +80,7 @@ export default function App() { } /> + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 10a4524..1ab0ffa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -35,6 +35,10 @@ async function refreshTokens() { }> if (json.code !== SUCCESS_CODE || !json.data) { storage.clearSession() + // 通知 AuthProvider 清空狀態並導回登入。 + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('haixun:session-expired')) + } throw new ApiError(json.code, json.message || 'refresh failed') } storage.setAccessToken(json.data.access_token) @@ -158,6 +162,7 @@ async function consumeAIEventStream( onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, + signal?: AbortSignal, ) { if (!res.ok || !res.body) { onError(await readStreamErrorMessage(res)) @@ -168,39 +173,77 @@ async function consumeAIEventStream( const decoder = new TextDecoder() let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const parts = buffer.split('\n\n') - buffer = parts.pop() ?? '' - for (const part of parts) { - const lines = part.split('\n') - let event = '' - let data = '' - for (const line of lines) { - if (line.startsWith('event:')) event = line.slice(6).trim() - if (line.startsWith('data:')) data = line.slice(5).trim() + // 確保 onDone / onError 只會被呼叫一次:避免串流提前關閉時 + // 外層 Promise 永遠不 settle(聊天輸入永久鎖死)。 + let settled = false + const finishDone = (finishReason?: string) => { + if (settled) return + settled = true + onDone(finishReason) + } + const finishError = (msg: string) => { + if (settled) return + settled = true + onError(msg) + } + + // 回傳 true 代表收到 done/error,串流應停止。 + const handlePart = (part: string): boolean => { + const lines = part.split('\n') + let event = '' + const dataLines: string[] = [] + for (const line of lines) { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, '')) + } + const data = dataLines.join('\n').trim() + if (!data) return false + try { + const parsed = JSON.parse(data) as { + type?: string + text?: string + finish_reason?: string + message?: string } - if (!data) continue - try { - const parsed = JSON.parse(data) as { - type?: string - text?: string - finish_reason?: string - message?: string - } - if (event === 'error' || parsed.type === 'error') { - onError(parsed.message || 'stream error') - return - } - if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text) - if (parsed.type === 'done') onDone(parsed.finish_reason) - } catch { - /* ignore malformed chunk */ + if (event === 'error' || parsed.type === 'error') { + finishError(parsed.message || 'stream error') + return true + } + if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text) + if (parsed.type === 'done') { + finishDone(parsed.finish_reason) + return true + } + } catch { + /* ignore malformed chunk */ + } + return false + } + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const parts = buffer.split('\n\n') + buffer = parts.pop() ?? '' + for (const part of parts) { + if (handlePart(part)) return } } + // 沖出殘餘 buffer(最後一段可能沒有結尾的空行)。 + if (buffer.trim()) handlePart(buffer) + } catch (err) { + if (signal?.aborted) { + finishDone() + return + } + finishError(err instanceof Error ? err.message : '串流連線中斷') + return } + + // reader 正常結束但未收到明確的 done 事件:仍要 settle,避免卡死。 + finishDone() } export async function streamIslanderChat( @@ -208,16 +251,26 @@ export async function streamIslanderChat( onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, + signal?: AbortSignal, ) { const memberToken = storage.getAccessToken() const headers: Record = { 'Content-Type': 'application/json' } if (memberToken) headers.Authorization = `Bearer ${memberToken}` - const res = await fetch('/api/v1/ai/islander/chat/stream', { - method: 'POST', - headers, - body: JSON.stringify(body), - }) - await consumeAIEventStream(res, onDelta, onDone, onError) + try { + const res = await fetch('/api/v1/ai/islander/chat/stream', { + method: 'POST', + headers, + body: JSON.stringify(body), + signal, + }) + await consumeAIEventStream(res, onDelta, onDone, onError, signal) + } catch (err) { + if (signal?.aborted) { + onDone() + return + } + onError(err instanceof Error ? err.message : '無法連線島民 API') + } } diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 3007fc9..54690fb 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -64,7 +64,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { return } refreshMember() - .catch(() => storage.clearSession()) + .catch(() => { + storage.clearSession() + setUid('') + setMember(null) + }) .finally(() => setLoading(false)) }, [refreshMember]) @@ -106,6 +110,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []) + // 當 client.ts 的 refresh 失敗(session 過期)時清空狀態,讓 ProtectedRoute 導回登入。 + useEffect(() => { + const onExpired = () => { + storage.clearSession() + setMember(null) + setUid('') + } + window.addEventListener('haixun:session-expired', onExpired) + return () => window.removeEventListener('haixun:session-expired', onExpired) + }, []) + const value = useMemo( () => ({ tenantId, @@ -117,7 +132,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { register, logout, refreshMember, - isAuthenticated: !!storage.getAccessToken(), + // 以 uid state 推導,確保登入/登出/過期時可反應式更新(不直接讀 storage)。 + isAuthenticated: !!uid, }), [tenantId, uid, member, loading, setTenantId, login, register, logout, refreshMember], ) diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..cb9fd9f --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,50 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +type ErrorBoundaryProps = { + children: ReactNode + fallback?: ReactNode +} + +type ErrorBoundaryState = { + hasError: boolean + message: string +} + +/** + * 攔截子樹的 render 例外,避免單一元件(例如渲染 AI 任意輸出的 IslanderMarkdown) + * 出錯就整頁白屏。 + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { hasError: false, message: '' } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { hasError: true, message: error instanceof Error ? error.message : '發生未預期錯誤' } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('ErrorBoundary caught error:', error, info.componentStack) + } + + private handleReset = () => { + this.setState({ hasError: false, message: '' }) + } + + render() { + if (!this.state.hasError) return this.props.children + if (this.props.fallback) return this.props.fallback + + return ( +
+

+ 這個區塊發生問題,請重試或重新整理頁面。 +

+

+ {this.state.message} +

+ +
+ ) + } +} diff --git a/frontend/src/components/JobMonitor.tsx b/frontend/src/components/JobMonitor.tsx index b32033c..be1d83b 100644 --- a/frontend/src/components/JobMonitor.tsx +++ b/frontend/src/components/JobMonitor.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { api } from '../api/client' -import { isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' +import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' import { jobTemplateLabel } from '../lib/jobTemplate' import { ANALYZE_COPY_MISSION_PIPELINE_STEPS, VIRAL_SCAN_PIPELINE_STEPS } from '../lib/copyFlow' import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph' @@ -347,11 +347,28 @@ export function JobMonitor() { } }, []) + const hasActiveJob = jobs.some((job) => isActiveJobStatus(job.status)) + useEffect(() => { load().catch(() => undefined) - const timer = window.setInterval(() => load().catch(() => undefined), 2000) - return () => window.clearInterval(timer) - }, [load]) + // 有進行中任務時每 3 秒刷新;否則放慢到 20 秒(仍能撈到別處啟動的任務)。 + // 分頁切到背景時暫停輪詢,避免無謂的流量與後端負載。 + const intervalMs = hasActiveJob ? 3000 : 20000 + const timer = window.setInterval(() => { + if (document.visibilityState === 'hidden') return + load().catch(() => undefined) + }, intervalMs) + + const onVisible = () => { + if (document.visibilityState === 'visible') load().catch(() => undefined) + } + document.addEventListener('visibilitychange', onVisible) + + return () => { + window.clearInterval(timer) + document.removeEventListener('visibilitychange', onVisible) + } + }, [load, hasActiveJob]) const syncClampedPosition = useCallback(() => { setPosition((prev) => { diff --git a/frontend/src/components/islander/IslanderCompanion.tsx b/frontend/src/components/islander/IslanderCompanion.tsx index 2ee8f84..7b559ea 100644 --- a/frontend/src/components/islander/IslanderCompanion.tsx +++ b/frontend/src/components/islander/IslanderCompanion.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { api, streamIslanderChat } from '../../api/client' import { @@ -47,6 +47,14 @@ export function IslanderCompanion() { const pageMeta = pageCtx?.runtimeMeta ?? null + // 取消尚在進行的 SSE 串流(送出新訊息或元件卸載時)。 + const streamAbortRef = useRef(null) + useEffect(() => { + return () => { + streamAbortRef.current?.abort() + } + }, []) + const setExpandedPersisted = useCallback((value: boolean | ((prev: boolean) => boolean)) => { setExpanded((prev) => { const next = typeof value === 'function' ? value(prev) : value @@ -89,6 +97,10 @@ export function IslanderCompanion() { setStreaming(true) setActing(false) + streamAbortRef.current?.abort() + const abortController = new AbortController() + streamAbortRef.current = abortController + const userMessage: IslanderChatMessage = { id: nextMessageId(), role: 'user', @@ -141,6 +153,7 @@ export function IslanderCompanion() { (delta) => onDelta(delta), () => resolve(), (message) => reject(new Error(message)), + abortController.signal, ) }) }, diff --git a/frontend/src/components/islander/IslanderMarkdown.tsx b/frontend/src/components/islander/IslanderMarkdown.tsx index 6c418da..bf764fd 100644 --- a/frontend/src/components/islander/IslanderMarkdown.tsx +++ b/frontend/src/components/islander/IslanderMarkdown.tsx @@ -5,7 +5,8 @@ import { Link } from 'react-router-dom' const markdownComponents: Components = { a: ({ href, children }) => { - if (href?.startsWith('/')) { + // 僅單斜線開頭視為站內路徑;`//evil.com` 等 protocol-relative 連結走外部分支,避免 open redirect。 + if (href?.startsWith('/') && !href.startsWith('//')) { return ( {children} diff --git a/frontend/src/lib/extensionSync.ts b/frontend/src/lib/extensionSync.ts index d241c7c..51329a8 100644 --- a/frontend/src/lib/extensionSync.ts +++ b/frontend/src/lib/extensionSync.ts @@ -28,8 +28,11 @@ export function normalizeExtensionSyncResult(raw: ExtensionSyncResult): Extensio } } +// 內容腳本注入在本頁,與本頁同 origin;限定 targetOrigin 避免把訊息(含 JWT)廣播給其他來源。 +const SAME_ORIGIN = window.location.origin + export function pingExtensionBridge() { - window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, '*') + window.postMessage({ type: 'HAIXUN_PING_EXTENSION' }, SAME_ORIGIN) } export function waitForExtensionBridge(timeoutMs = 8000): Promise { @@ -39,6 +42,7 @@ export function waitForExtensionBridge(timeoutMs = 8000): Promise { const started = Date.now() const onMessage = (event: MessageEvent) => { + if (event.origin !== SAME_ORIGIN) return if (event.source !== window) return if (event.data?.type !== 'HAIXUN_EXTENSION_READY') return cleanup() @@ -84,6 +88,7 @@ export function requestExtensionSync(input: { }, 30000) function onMessage(event: MessageEvent) { + if (event.origin !== SAME_ORIGIN) return if (event.source !== window) return if (event.data?.type !== 'HAIXUN_THREADS_SYNC_RESULT') return window.clearTimeout(timeout) @@ -100,7 +105,7 @@ export function requestExtensionSync(input: { accessToken: input.accessToken, apiVersion: 'go-v1', }, - '*', + SAME_ORIGIN, ) }) } \ No newline at end of file diff --git a/frontend/src/lib/islander/actionExecutor.ts b/frontend/src/lib/islander/actionExecutor.ts index 618fee5..2fbe37f 100644 --- a/frontend/src/lib/islander/actionExecutor.ts +++ b/frontend/src/lib/islander/actionExecutor.ts @@ -189,6 +189,18 @@ type ExecuteOptions = { snapshot?: PageSnapshot } +function isDangerousAction(type: string): boolean { + return (ISLANDER_CONFIG.dangerousActionTypes as readonly string[]).includes(type) +} + +// 對危險動作要求真人確認;AI 自填的 confirm 不足以放行。 +function confirmDangerousAction(type: string): boolean { + if (typeof window === 'undefined' || typeof window.confirm !== 'function') return false + return window.confirm( + `島民想替你執行「${type}」,這會實際送出 / 發布或啟動背景任務。確定要執行嗎?`, + ) +} + export async function executeIslanderActions(opts: ExecuteOptions): Promise<{ results: IslanderActionResult[] snapshotText: string @@ -197,6 +209,10 @@ export async function executeIslanderActions(opts: ExecuteOptions): Promise<{ const results: IslanderActionResult[] = [] for (const action of opts.actions) { + if (isDangerousAction(action.type) && !confirmDangerousAction(action.type)) { + results.push({ action, ok: false, detail: '使用者未確認,已略過此操作' }) + break + } const result = await runAction(action, opts.navigate, snapshot) results.push(result) if (!result.ok && action.type !== 'wait') break diff --git a/frontend/src/lib/islander/config.ts b/frontend/src/lib/islander/config.ts index cd9262e..29bcb8c 100644 --- a/frontend/src/lib/islander/config.ts +++ b/frontend/src/lib/islander/config.ts @@ -38,6 +38,18 @@ export const ISLANDER_CONFIG = { `[data-islander-label]`, ], blockedClickPatterns: [/登出/i, /logout/i], + // 這些 action 會實際對外發布、啟動背景/付費任務或寫入後端, + // 必須由真人確認(window.confirm),不能只靠 AI 在 JSON 自填 confirm。 + // 避免被海巡抓回的不可信貼文做 prompt injection 觸發自動發文。 + dangerousActionTypes: [ + 'publishOutreach', + 'publishCopyDraft', + 'startViralScan', + 'startCopyMissionAnalyze', + 'startCopyMissionScan', + 'generateCopyMatrix', + 'generateCopyDraft', + ] as string[], defaultSuggestions: [] as string[], highlightClass: 'ac-islander-target-highlight', highlightDurationMs: 1800, diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index a86998d..b1d96dd 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -25,5 +25,8 @@ export const storage = { localStorage.removeItem(KEYS.refreshToken) localStorage.removeItem(KEYS.uid) localStorage.removeItem(KEYS.activeThreadsAccountId) + // 高敏感的 AI provider key 不可在登出後殘留(共用裝置)。 + localStorage.removeItem(KEYS.aiProviderToken) + localStorage.removeItem(KEYS.tenantId) }, } \ No newline at end of file diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..d0a5704 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,12 @@ +# Infra docker compose 環境變數範本 +# 複製成 infra/.env 後填入實際值;.env 不要 commit。 + +# --- Mongo --- +MONGO_PORT=27017 +MONGO_ROOT_USER=haixun +MONGO_ROOT_PASSWORD=change-me-mongo-pass +MONGO_DATABASE=haixun + +# --- Redis --- +REDIS_PORT=6379 +REDIS_PASSWORD=change-me-redis-pass diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..afb57fd --- /dev/null +++ b/infra/README.md @@ -0,0 +1,103 @@ +# 巡樓部署 (infra) + +部署拓樸: + +``` +瀏覽器 → nginx(systemd, :80/:443) + ├─ 靜態前端 /var/www/haixun (frontend/dist) + └─ /api 反向代理 → Go gateway (systemd, 127.0.0.1:8890) +Go gateway / Go worker (systemd) → Mongo / Redis (docker compose, 綁 127.0.0.1) +Node playwright worker (systemd) → 透過 HTTP 打 gateway +``` + +- 資料服務(Mongo/Redis)用 docker compose,只綁 `127.0.0.1`。 +- Go gateway / Go worker / Node worker 都是 systemd 原生服務。 +- secret 一律放 `/opt/haixun/etc/haixun.env`(不進 repo),yaml 用 `${VAR}` 讀取。 + +## 目錄 + +``` +infra/ + docker-compose.yml # mongo + redis + .env.example # compose 用環境變數 + etc/haixun.env.example # systemd EnvironmentFile 範本(secret) + nginx/haixun.conf # 靜態前端 + /api 反代 + SSE + systemd/ + haixun-gateway.service + haixun-worker.service + haixun-node-worker.service +``` + +## 1. 起資料服務 (docker) + +```bash +cd infra +cp .env.example .env # 填入 Mongo/Redis 密碼 +docker compose --env-file .env up -d +docker compose ps +``` + +## 2. 建置產物(本機或 CI) + +```bash +make build # 前端 dist + 兩個 linux Go binary(backend/bin/) +``` + +## 3. 安裝到目標主機 + +於目標主機(需 root): + +```bash +sudo make install +``` + +`make install` 會: + +1. 建立使用者 `haixun` 與目錄 `/opt/haixun/{bin,etc,node-worker}`、`/var/www/haixun`。 +2. 複製 `backend/bin/{gateway,worker}`、`backend/etc/gateway.prod.yaml`、`backend/etc/gateway.worker.prod.yaml`。 +3. 複製 `frontend/dist/*` → `/var/www/haixun`。 +4. 複製 `backend/worker/*`(Node worker)→ `/opt/haixun/node-worker`,並 `npm ci` + `npx playwright install`。 +5. 安裝 `infra/systemd/*.service` 與 `infra/nginx/haixun.conf`。 + +接著手動建立 secret 檔(**只做一次**): + +```bash +sudo cp infra/etc/haixun.env.example /opt/haixun/etc/haixun.env +sudo chmod 600 /opt/haixun/etc/haixun.env +sudoedit /opt/haixun/etc/haixun.env # 填入實際 secret +``` + +## 4. 初始化資料庫與 admin 帳號(只做一次) + +Mongo 起來、secret 填好後,建立索引 / 權限 catalog / role_permissions,並建立第一個 admin: + +```bash +# 可在 haixun.env 內設定 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD,或在這裡用環境變數覆寫 +sudo make prod-init +# 等同:source /opt/haixun/etc/haixun.env 後執行 /opt/haixun/bin/tool init -f /opt/haixun/etc/gateway.prod.yaml +``` + +之後一般使用者可走 `POST /api/v1/auth/register` 自助註冊(前端登入頁)。 + +## 5. 啟用服務 + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now haixun-gateway haixun-worker haixun-node-worker +sudo nginx -t && sudo systemctl reload nginx +``` + +## 6. 健康檢查 + +```bash +curl http://127.0.0.1:8890/api/v1/health +sudo systemctl status haixun-gateway haixun-worker haixun-node-worker +journalctl -u haixun-gateway -f +``` + +## 產生 secret + +```bash +openssl rand -base64 48 # JWT access / refresh / worker secret +openssl rand -base64 32 # HAIXUN_SECRETS_KEY(機敏資料落地加密) +``` diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..fec1b16 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,42 @@ +# 巡樓資料服務:Mongo + Redis +# 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。 +# 啟動:docker compose -f infra/docker-compose.yml --env-file infra/.env up -d +name: haixun-infra + +services: + mongo: + image: mongo:7 + restart: unless-stopped + ports: + - "127.0.0.1:${MONGO_PORT:-27017}:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-haixun} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD is required} + MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-haixun} + volumes: + - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + redis: + image: redis:7 + restart: unless-stopped + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}", "--appendonly", "yes"] + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + mongo_data: + redis_data: diff --git a/infra/etc/haixun.env.example b/infra/etc/haixun.env.example new file mode 100644 index 0000000..903366c --- /dev/null +++ b/infra/etc/haixun.env.example @@ -0,0 +1,23 @@ +# 部署到目標主機的 /opt/haixun/etc/haixun.env(chmod 600,不要 commit 實值) +# gateway.prod.yaml / gateway.worker.yaml 用 ${VAR} 讀取這些值(go-zero conf.UseEnv)。 + +# Mongo(含 docker compose 設定的帳密;authSource=admin) +HAIXUN_MONGO_URI=mongodb://haixun:change-me-mongo-pass@127.0.0.1:27017/?authSource=admin +HAIXUN_MONGO_DB=haixun + +# Redis +HAIXUN_REDIS_ADDR=127.0.0.1:6379 +HAIXUN_REDIS_PASSWORD=change-me-redis-pass + +# JWT secret(請用 openssl rand -base64 48 產生,兩把不同) +HAIXUN_JWT_ACCESS_SECRET=replace-with-strong-random +HAIXUN_JWT_REFRESH_SECRET=replace-with-another-strong-random + +# 內部 worker secret(gateway 與 node worker 必須一致) +HAIXUN_WORKER_SECRET=replace-with-strong-random + +# 機敏資料落地加密金鑰(base64 編碼的 32 bytes;openssl rand -base64 32) +HAIXUN_SECRETS_KEY=replace-with-base64-32-bytes + +# Node worker 連線設定 +HAIXUN_BACKEND_URL=http://127.0.0.1:8890 diff --git a/infra/nginx/haixun.conf b/infra/nginx/haixun.conf new file mode 100644 index 0000000..69db9e0 --- /dev/null +++ b/infra/nginx/haixun.conf @@ -0,0 +1,56 @@ +# 巡樓 Console nginx 設定 +# 安裝:cp infra/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf && nginx -t && systemctl reload nginx +# 前端靜態檔部署在 /var/www/haixun(make install 會放 frontend/dist 內容)。 +# /api 反向代理到本機 systemd 跑的 Go gateway (127.0.0.1:8890),含 SSE 串流設定。 + +upstream haixun_gateway { + server 127.0.0.1:8890; + keepalive 32; +} + +server { + listen 80; + listen [::]:80; + server_name _; + + root /var/www/haixun; + index index.html; + + # 安全標頭 + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "0" always; + + # 靜態資源快取(vite build 帶 hash 檔名) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # API 反向代理(一般 JSON 與 SSE 共用) + location /api/ { + proxy_pass http://haixun_gateway; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Authorization(provider token)與 X-Member-Authorization(會員 JWT)由 nginx 預設轉發; + # 以下確保 SSE 串流不被緩衝、長連線不被提前中斷。 + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # SPA:所有非檔案路徑都回 index.html,交給 react-router + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/infra/systemd/haixun-gateway.service b/infra/systemd/haixun-gateway.service new file mode 100644 index 0000000..9030f3f --- /dev/null +++ b/infra/systemd/haixun-gateway.service @@ -0,0 +1,25 @@ +[Unit] +Description=Haixun Gateway API (go-zero) +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun +# secrets(JWT / Mongo URI / Redis 密碼 / worker secret / 加密金鑰)放這裡,不進 repo +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/opt/haixun/bin/gateway -f /opt/haixun/etc/gateway.prod.yaml +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +# 加固 +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/infra/systemd/haixun-node-worker.service b/infra/systemd/haixun-node-worker.service new file mode 100644 index 0000000..7221767 --- /dev/null +++ b/infra/systemd/haixun-node-worker.service @@ -0,0 +1,23 @@ +[Unit] +Description=Haixun Node Playwright Worker (style-8d) +After=network-online.target haixun-gateway.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun/node-worker +# 至少需要 HAIXUN_BACKEND_URL 與 HAIXUN_WORKER_SECRET(與 gateway 的 InternalWorker.Secret 一致) +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/usr/bin/npx tsx style-8d-worker.ts +Restart=always +RestartSec=10 +LimitNOFILE=65535 + +NoNewPrivileges=true +ProtectSystem=full +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/infra/systemd/haixun-worker.service b/infra/systemd/haixun-worker.service new file mode 100644 index 0000000..df7a3e6 --- /dev/null +++ b/infra/systemd/haixun-worker.service @@ -0,0 +1,23 @@ +[Unit] +Description=Haixun Go Job Worker +After=network-online.target docker.service haixun-gateway.service +Wants=network-online.target + +[Service] +Type=simple +User=haixun +Group=haixun +WorkingDirectory=/opt/haixun +EnvironmentFile=/opt/haixun/etc/haixun.env +ExecStart=/opt/haixun/bin/worker -f /opt/haixun/etc/gateway.worker.prod.yaml +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target