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