feat: add internal workbench admin api
This commit is contained in:
@@ -14,11 +14,14 @@
|
|||||||
- `/v1/workbench/reports/daily*`
|
- `/v1/workbench/reports/daily*`
|
||||||
- `/v1/workbench/reports/monthly*`
|
- `/v1/workbench/reports/monthly*`
|
||||||
- `/v1/workbench/executive-mailbox/*`
|
- `/v1/workbench/executive-mailbox/*`
|
||||||
|
- `/internal/admin/workbench/*`,HotGo 后台内部管理接口,使用服务间 HMAC 鉴权
|
||||||
|
|
||||||
## 本地运行
|
## 本地运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
IM_WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
|
IM_WORKBENCH_CONFIG=configs/workbench.local.json \
|
||||||
|
IM_WORKBENCH_INTERNAL_SECRET=local-workbench-secret \
|
||||||
|
go run ./cmd/workbench
|
||||||
```
|
```
|
||||||
|
|
||||||
默认监听 `:6061`,默认连接本地 `tinode` 库和本地 Redis。
|
默认监听 `:6061`,默认连接本地 `tinode` 库和本地 Redis。
|
||||||
@@ -43,6 +46,13 @@ X-Request-ID: {uuid}
|
|||||||
|
|
||||||
服务从 Redis 读取 `im:session:token:{sha256(token)}` 校验登录态。
|
服务从 Redis 读取 `im:session:token:{sha256(token)}` 校验登录态。
|
||||||
|
|
||||||
|
HotGo 后台访问 `/internal/admin/workbench/*` 时需要配置同一组内部鉴权参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IM_WORKBENCH_INTERNAL_SERVICE_NAME=hotgo-admin
|
||||||
|
IM_WORKBENCH_INTERNAL_SECRET={shared-secret}
|
||||||
|
```
|
||||||
|
|
||||||
## Kafka 事件
|
## Kafka 事件
|
||||||
|
|
||||||
工作台写操作统一投递到 `im.workbench.event`。本地配置默认关闭 Kafka:
|
工作台写操作统一投递到 `im.workbench.event`。本地配置默认关闭 Kafka:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"allow_dev_header_auth": true,
|
"allow_dev_header_auth": true,
|
||||||
"required_tenant_code": true,
|
"required_tenant_code": true,
|
||||||
"required_api_key": false,
|
"required_api_key": false,
|
||||||
"skip_session_in_dev": false
|
"skip_session_in_dev": false,
|
||||||
|
"internal_service_name": "hotgo-admin",
|
||||||
|
"internal_secret": "local-workbench-secret"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
"allow_dev_header_auth": false,
|
"allow_dev_header_auth": false,
|
||||||
"required_tenant_code": true,
|
"required_tenant_code": true,
|
||||||
"required_api_key": true,
|
"required_api_key": true,
|
||||||
"skip_session_in_dev": false
|
"skip_session_in_dev": false,
|
||||||
|
"internal_service_name": "hotgo-admin",
|
||||||
|
"internal_secret": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ func New(cfg *config.Config) (*App, error) {
|
|||||||
store: st,
|
store: st,
|
||||||
redis: redisClient,
|
redis: redisClient,
|
||||||
events: publisher,
|
events: publisher,
|
||||||
api: httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher),
|
api: httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher, cfg.Auth),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ type AuthConfig struct {
|
|||||||
RequiredAPIKey bool `json:"required_api_key"`
|
RequiredAPIKey bool `json:"required_api_key"`
|
||||||
SkipSessionInDev bool `json:"skip_session_in_dev"`
|
SkipSessionInDev bool `json:"skip_session_in_dev"`
|
||||||
TrustedInternalAddr string `json:"trusted_internal_addr"`
|
TrustedInternalAddr string `json:"trusted_internal_addr"`
|
||||||
|
InternalServiceName string `json:"internal_service_name"`
|
||||||
|
InternalSecret string `json:"internal_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -109,6 +111,8 @@ func applyEnv(cfg *Config) {
|
|||||||
setString(&cfg.Kafka.ClientID, "IM_WORKBENCH_KAFKA_CLIENT_ID")
|
setString(&cfg.Kafka.ClientID, "IM_WORKBENCH_KAFKA_CLIENT_ID")
|
||||||
setString(&cfg.Auth.SessionKeyPattern, "IM_WORKBENCH_SESSION_KEY_PATTERN")
|
setString(&cfg.Auth.SessionKeyPattern, "IM_WORKBENCH_SESSION_KEY_PATTERN")
|
||||||
setString(&cfg.Auth.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR")
|
setString(&cfg.Auth.TrustedInternalAddr, "IM_WORKBENCH_TRUSTED_INTERNAL_ADDR")
|
||||||
|
setString(&cfg.Auth.InternalServiceName, "IM_WORKBENCH_INTERNAL_SERVICE_NAME")
|
||||||
|
setString(&cfg.Auth.InternalSecret, "IM_WORKBENCH_INTERNAL_SECRET")
|
||||||
if brokers := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BROKERS")); brokers != "" {
|
if brokers := strings.TrimSpace(os.Getenv("IM_WORKBENCH_KAFKA_BROKERS")); brokers != "" {
|
||||||
cfg.Kafka.Brokers = splitCSV(brokers)
|
cfg.Kafka.Brokers = splitCSV(brokers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
page := queryInt(r, "page", 1)
|
||||||
|
perPage := queryInt(r, "per_page", 20)
|
||||||
|
var enabled *int
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("enabled")); raw != "" {
|
||||||
|
if value, err := strconv.Atoi(raw); err == nil {
|
||||||
|
enabled = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items, total, err := a.store.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"items": adminWorkbenchModulePayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||||
|
case http.MethodPost:
|
||||||
|
var body adminWorkbenchModuleRequest
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := a.store.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{
|
||||||
|
ID: body.ID, Code: body.Code, Name: body.Name, IconFileID: body.IconFileID, Route: body.Route,
|
||||||
|
SortOrder: body.SortOrder, UnreadKey: body.UnreadKey, Enabled: body.Enabled, Permission: body.Permission,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, adminWorkbenchModulePayload(*item))
|
||||||
|
default:
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Items []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]store.AdminWorkbenchReorderItem, 0, len(body.Items))
|
||||||
|
for _, item := range body.Items {
|
||||||
|
items = append(items, store.AdminWorkbenchReorderItem{Code: item.Code, SortOrder: item.SortOrder})
|
||||||
|
}
|
||||||
|
if err := a.store.ReorderAdminWorkbenchModules(r.Context(), items); err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
cfg, err := a.store.AdminMailboxConfig(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, adminMailboxConfigPayload(*cfg))
|
||||||
|
case http.MethodPost:
|
||||||
|
var body adminMailboxConfigRequest
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{
|
||||||
|
Enabled: body.Enabled, AllowAnonymous: body.AllowAnonymous, RequiredFields: body.RequiredFields,
|
||||||
|
MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, NoticeTitle: body.NoticeTitle, NoticeContent: body.NoticeContent,
|
||||||
|
}); err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"ok": true})
|
||||||
|
default:
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
cfg, err := a.store.AdminFeedbackConfig(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, adminFeedbackConfigPayload(*cfg))
|
||||||
|
case http.MethodPost:
|
||||||
|
var body adminFeedbackConfigRequest
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{
|
||||||
|
Categories: body.Categories, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy,
|
||||||
|
AllowContact: body.AllowContact, DiagnosticsNotice: body.DiagnosticsNotice,
|
||||||
|
}); err != nil {
|
||||||
|
writeStoreError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, map[string]any{"ok": true})
|
||||||
|
default:
|
||||||
|
methodNotAllowed(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminWorkbenchModuleRequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IconFileID string `json:"icon_file_id"`
|
||||||
|
Route string `json:"route"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
UnreadKey string `json:"unread_key"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
Permission string `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminMailboxConfigRequest struct {
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
AllowAnonymous int `json:"allow_anonymous"`
|
||||||
|
RequiredFields string `json:"required_fields"`
|
||||||
|
MaxLength int `json:"max_length"`
|
||||||
|
AttachmentPolicy string `json:"attachment_policy"`
|
||||||
|
NoticeTitle string `json:"notice_title"`
|
||||||
|
NoticeContent string `json:"notice_content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminFeedbackConfigRequest struct {
|
||||||
|
Categories string `json:"categories"`
|
||||||
|
MaxLength int `json:"max_length"`
|
||||||
|
AttachmentPolicy string `json:"attachment_policy"`
|
||||||
|
AllowContact int `json:"allow_contact"`
|
||||||
|
DiagnosticsNotice string `json:"diagnostics_notice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminWorkbenchModulePayloads(items []store.AdminWorkbenchModule) []map[string]any {
|
||||||
|
res := make([]map[string]any, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
res = append(res, adminWorkbenchModulePayload(item))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminWorkbenchModulePayload(item store.AdminWorkbenchModule) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": item.ID,
|
||||||
|
"code": item.Code,
|
||||||
|
"name": item.Name,
|
||||||
|
"iconFileId": item.IconFileID,
|
||||||
|
"route": item.Route,
|
||||||
|
"sortOrder": item.SortOrder,
|
||||||
|
"unreadKey": item.UnreadKey,
|
||||||
|
"enabled": item.Enabled,
|
||||||
|
"permission": item.Permission,
|
||||||
|
"createdAt": formatAdminTime(item.CreatedAt),
|
||||||
|
"updatedAt": formatAdminTime(item.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminMailboxConfigPayload(cfg store.AdminMailboxConfig) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": cfg.ID,
|
||||||
|
"enabled": cfg.Enabled,
|
||||||
|
"allowAnonymous": cfg.AllowAnonymous,
|
||||||
|
"requiredFields": cfg.RequiredFields,
|
||||||
|
"maxLength": cfg.MaxLength,
|
||||||
|
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||||
|
"noticeTitle": cfg.NoticeTitle,
|
||||||
|
"noticeContent": cfg.NoticeContent,
|
||||||
|
"noticeUpdatedAt": formatAdminTime(cfg.NoticeUpdatedAt),
|
||||||
|
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminFeedbackConfigPayload(cfg store.AdminFeedbackConfig) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": cfg.ID,
|
||||||
|
"categories": cfg.Categories,
|
||||||
|
"maxLength": cfg.MaxLength,
|
||||||
|
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||||
|
"allowContact": cfg.AllowContact,
|
||||||
|
"diagnosticsNotice": cfg.DiagnosticsNotice,
|
||||||
|
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryInt(r *http.Request, key string, fallback int) int {
|
||||||
|
value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(key)))
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAdminTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||||
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
"git.118im.com/im_team/im_workbench/internal/events"
|
"git.118im.com/im_team/im_workbench/internal/events"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/im_workbench/internal/store"
|
||||||
)
|
)
|
||||||
@@ -21,6 +22,7 @@ type API struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
auth *auth.Authenticator
|
auth *auth.Authenticator
|
||||||
events events.Publisher
|
events events.Publisher
|
||||||
|
authCfg config.AuthConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
const workbenchEventPublishTimeout = 15 * time.Second
|
const workbenchEventPublishTimeout = 15 * time.Second
|
||||||
@@ -30,11 +32,11 @@ const (
|
|||||||
maxMailboxContentLength = 5000
|
maxMailboxContentLength = 5000
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher) *API {
|
func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher, authCfg config.AuthConfig) *API {
|
||||||
if publisher == nil {
|
if publisher == nil {
|
||||||
publisher = events.NoopPublisher{}
|
publisher = events.NoopPublisher{}
|
||||||
}
|
}
|
||||||
return &API{store: st, auth: authenticator, events: publisher}
|
return &API{store: st, auth: authenticator, events: publisher, authCfg: authCfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) Register(mux *http.ServeMux) {
|
func (a *API) Register(mux *http.ServeMux) {
|
||||||
@@ -59,6 +61,11 @@ func (a *API) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/v1/workbench/reports/monthly/", a.withAuth(a.monthlyReportResource))
|
mux.HandleFunc("/v1/workbench/reports/monthly/", a.withAuth(a.monthlyReportResource))
|
||||||
mux.HandleFunc("/v1/workbench/executive-mailbox/config", a.withAuth(a.mailboxConfig))
|
mux.HandleFunc("/v1/workbench/executive-mailbox/config", a.withAuth(a.mailboxConfig))
|
||||||
mux.HandleFunc("/v1/workbench/executive-mailbox/submissions", a.withAuth(a.mailboxSubmissions))
|
mux.HandleFunc("/v1/workbench/executive-mailbox/submissions", a.withAuth(a.mailboxSubmissions))
|
||||||
|
mux.HandleFunc("/internal/admin/workbench/apps", a.withInternalAdminAuth(a.adminWorkbenchApps))
|
||||||
|
mux.HandleFunc("/internal/admin/workbench/apps/disable", a.withInternalAdminAuth(a.adminWorkbenchAppDisable))
|
||||||
|
mux.HandleFunc("/internal/admin/workbench/apps/reorder", a.withInternalAdminAuth(a.adminWorkbenchAppsReorder))
|
||||||
|
mux.HandleFunc("/internal/admin/workbench/mailbox-config", a.withInternalAdminAuth(a.adminMailboxConfig))
|
||||||
|
mux.HandleFunc("/internal/admin/workbench/feedback-config", a.withInternalAdminAuth(a.adminFeedbackConfig))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
|
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const internalSignatureWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
func (a *API) withInternalAdminAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.Body.Close()
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
if err := a.verifyInternalAdminSignature(r, body); err != nil {
|
||||||
|
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) verifyInternalAdminSignature(r *http.Request, body []byte) error {
|
||||||
|
secret := strings.TrimSpace(a.authCfg.InternalSecret)
|
||||||
|
if secret == "" {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
serviceName := strings.TrimSpace(r.Header.Get("X-Service-Name"))
|
||||||
|
if expected := strings.TrimSpace(a.authCfg.InternalServiceName); expected != "" && serviceName != expected {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
timestampRaw := strings.TrimSpace(r.Header.Get("X-Timestamp"))
|
||||||
|
nonce := strings.TrimSpace(r.Header.Get("X-Nonce"))
|
||||||
|
signature := strings.TrimSpace(r.Header.Get("X-Signature"))
|
||||||
|
if timestampRaw == "" || nonce == "" || signature == "" {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
ts, err := strconv.ParseInt(timestampRaw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
when := time.Unix(ts, 0)
|
||||||
|
if time.Since(when) > internalSignatureWindow || time.Until(when) > internalSignatureWindow {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
bodyHash := sha256.Sum256(body)
|
||||||
|
signingText := strings.Join([]string{
|
||||||
|
r.Method,
|
||||||
|
r.URL.RequestURI(),
|
||||||
|
hex.EncodeToString(bodyHash[:]),
|
||||||
|
timestampRaw,
|
||||||
|
nonce,
|
||||||
|
}, "\n")
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(signingText))
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminWorkbenchModule struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
Code string `db:"code"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
IconFileID string `db:"icon_file_id"`
|
||||||
|
Route string `db:"route"`
|
||||||
|
SortOrder int `db:"sort_order"`
|
||||||
|
UnreadKey string `db:"unread_key"`
|
||||||
|
Enabled int `db:"enabled"`
|
||||||
|
Permission string `db:"permission"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminWorkbenchModuleInput struct {
|
||||||
|
ID int64
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
IconFileID string
|
||||||
|
Route string
|
||||||
|
SortOrder int
|
||||||
|
UnreadKey string
|
||||||
|
Enabled int
|
||||||
|
Permission string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminWorkbenchReorderItem struct {
|
||||||
|
Code string
|
||||||
|
SortOrder int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminMailboxConfig struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
Enabled int `db:"enabled"`
|
||||||
|
AllowAnonymous int `db:"allow_anonymous"`
|
||||||
|
RequiredFields string `db:"required_fields"`
|
||||||
|
MaxLength int `db:"max_length"`
|
||||||
|
AttachmentPolicy string `db:"attachment_policy"`
|
||||||
|
NoticeTitle string `db:"notice_title"`
|
||||||
|
NoticeContent string `db:"notice_content"`
|
||||||
|
NoticeUpdatedAt time.Time `db:"notice_updated_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminFeedbackConfig struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
Categories string `db:"categories"`
|
||||||
|
MaxLength int `db:"max_length"`
|
||||||
|
AttachmentPolicy string `db:"attachment_policy"`
|
||||||
|
AllowContact int `db:"allow_contact"`
|
||||||
|
DiagnosticsNotice string `db:"diagnostics_notice"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminWorkbenchModules(ctx context.Context, page, perPage int, code, name string, enabled *int) ([]AdminWorkbenchModule, int, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage <= 0 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
if perPage > 200 {
|
||||||
|
perPage = 200
|
||||||
|
}
|
||||||
|
where := []string{"1=1"}
|
||||||
|
args := []any{}
|
||||||
|
if code = strings.TrimSpace(code); code != "" {
|
||||||
|
where = append(where, "code LIKE ?")
|
||||||
|
args = append(args, "%"+code+"%")
|
||||||
|
}
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
where = append(where, "name LIKE ?")
|
||||||
|
args = append(args, "%"+name+"%")
|
||||||
|
}
|
||||||
|
if enabled != nil {
|
||||||
|
where = append(where, "enabled=?")
|
||||||
|
args = append(args, *enabled)
|
||||||
|
}
|
||||||
|
whereSQL := strings.Join(where, " AND ")
|
||||||
|
var total int
|
||||||
|
if err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_workbench_apps WHERE "+whereSQL, args...); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
queryArgs := append([]any{}, args...)
|
||||||
|
queryArgs = append(queryArgs, (page-1)*perPage, perPage)
|
||||||
|
var items []AdminWorkbenchModule
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at
|
||||||
|
FROM im_workbench_apps WHERE `+whereSQL+` ORDER BY sort_order ASC,id ASC LIMIT ?,?`, queryArgs...)
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertAdminWorkbenchModule(ctx context.Context, in AdminWorkbenchModuleInput) (*AdminWorkbenchModule, error) {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
in.Code = strings.TrimSpace(in.Code)
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
in.IconFileID = strings.TrimSpace(in.IconFileID)
|
||||||
|
in.Route = strings.TrimSpace(in.Route)
|
||||||
|
in.UnreadKey = strings.TrimSpace(in.UnreadKey)
|
||||||
|
in.Permission = strings.TrimSpace(in.Permission)
|
||||||
|
if in.Permission == "" {
|
||||||
|
in.Permission = "enabled"
|
||||||
|
}
|
||||||
|
if in.ID > 0 {
|
||||||
|
res, err := s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET code=?,name=?,icon_file_id=?,route=?,sort_order=?,unread_key=?,enabled=?,permission=?,updated_at=? WHERE id=?`,
|
||||||
|
in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, in.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.adminWorkbenchModuleByID(ctx, in.ID)
|
||||||
|
}
|
||||||
|
res, err := s.db.ExecContext(ctx, `INSERT INTO im_workbench_apps(code,name,icon_file_id,route,sort_order,unread_key,enabled,permission,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?)`, in.Code, in.Name, in.IconFileID, in.Route, in.SortOrder, in.UnreadKey, in.Enabled, in.Permission, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return s.adminWorkbenchModuleByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DisableAdminWorkbenchModule(ctx context.Context, id int64, code string) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
var res sql.Result
|
||||||
|
var err error
|
||||||
|
if id > 0 {
|
||||||
|
res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE id=?`, now, id)
|
||||||
|
} else {
|
||||||
|
res, err = s.db.ExecContext(ctx, `UPDATE im_workbench_apps SET enabled=0,updated_at=? WHERE code=?`, now, strings.TrimSpace(code))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderAdminWorkbenchModules(ctx context.Context, items []AdminWorkbenchReorderItem) error {
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
for _, item := range items {
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE im_workbench_apps SET sort_order=?,updated_at=? WHERE code=?`, item.SortOrder, now, strings.TrimSpace(item.Code)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminMailboxConfig(ctx context.Context) (*AdminMailboxConfig, error) {
|
||||||
|
var cfg AdminMailboxConfig
|
||||||
|
if err := s.db.GetContext(ctx, &cfg, `SELECT id,enabled+0 AS enabled,allow_anonymous+0 AS allow_anonymous,CAST(required_fields AS CHAR) AS required_fields,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,notice_title,notice_content,notice_updated_at,updated_at FROM im_mailbox_config WHERE id=1`); err != nil {
|
||||||
|
if errorsIsNoRows(err) {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
return &AdminMailboxConfig{ID: 1, Enabled: 1, AllowAnonymous: 1, RequiredFields: `["subject","content"]`, MaxLength: 5000, AttachmentPolicy: `{"max_files":5,"purpose":"executive_mailbox"}`, NoticeTitle: "总经理信箱", NoticeContent: "请如实填写诉求,匿名提交将按企业政策保护展示身份。", NoticeUpdatedAt: now, UpdatedAt: now}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAdminMailboxConfig(ctx context.Context, cfg AdminMailboxConfig) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO im_mailbox_config(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content,notice_updated_at,created_at,updated_at)
|
||||||
|
VALUES(1,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE enabled=VALUES(enabled),allow_anonymous=VALUES(allow_anonymous),required_fields=VALUES(required_fields),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),notice_title=VALUES(notice_title),notice_content=VALUES(notice_content),notice_updated_at=VALUES(notice_updated_at),updated_at=VALUES(updated_at)`,
|
||||||
|
cfg.Enabled, cfg.AllowAnonymous, defaultString(cfg.RequiredFields, `["subject","content"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"executive_mailbox"}`), cfg.NoticeTitle, cfg.NoticeContent, now, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminFeedbackConfig(ctx context.Context) (*AdminFeedbackConfig, error) {
|
||||||
|
var cfg AdminFeedbackConfig
|
||||||
|
if err := s.db.GetContext(ctx, &cfg, `SELECT id,CAST(categories AS CHAR) AS categories,max_length,CAST(attachment_policy AS CHAR) AS attachment_policy,allow_contact+0 AS allow_contact,diagnostics_notice,updated_at FROM im_feedback_config WHERE id=1`); err != nil {
|
||||||
|
if errorsIsNoRows(err) {
|
||||||
|
return &AdminFeedbackConfig{ID: 1, Categories: `["bug","suggestion","complaint","other"]`, MaxLength: 1000, AttachmentPolicy: `{"max_files":5,"purpose":"feedback"}`, AllowContact: 1, DiagnosticsNotice: "仅在你同意时上传脱敏诊断信息。", UpdatedAt: time.Now().UTC().Round(time.Millisecond)}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAdminFeedbackConfig(ctx context.Context, cfg AdminFeedbackConfig) error {
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO im_feedback_config(id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at)
|
||||||
|
VALUES(1,?,?,?,?,?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE categories=VALUES(categories),max_length=VALUES(max_length),attachment_policy=VALUES(attachment_policy),allow_contact=VALUES(allow_contact),diagnostics_notice=VALUES(diagnostics_notice),updated_at=VALUES(updated_at)`,
|
||||||
|
defaultString(cfg.Categories, `["bug","suggestion","complaint","other"]`), cfg.MaxLength, defaultString(cfg.AttachmentPolicy, `{"max_files":5,"purpose":"feedback"}`), cfg.AllowContact, cfg.DiagnosticsNotice, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) adminWorkbenchModuleByID(ctx context.Context, id int64) (*AdminWorkbenchModule, error) {
|
||||||
|
var item AdminWorkbenchModule
|
||||||
|
if err := s.db.GetContext(ctx, &item, `SELECT id,code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,IFNULL(unread_key,'') AS unread_key,enabled+0 AS enabled,IFNULL(permission,'enabled') AS permission,created_at,updated_at FROM im_workbench_apps WHERE id=?`, id); err != nil {
|
||||||
|
if errorsIsNoRows(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorsIsNoRows(err error) bool {
|
||||||
|
return err == sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultString(value string, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user