feat: add internal workbench admin api
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+12
-5
@@ -13,14 +13,16 @@ import (
|
||||
"time"
|
||||
|
||||
"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/store"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
store *store.Store
|
||||
auth *auth.Authenticator
|
||||
events events.Publisher
|
||||
store *store.Store
|
||||
auth *auth.Authenticator
|
||||
events events.Publisher
|
||||
authCfg config.AuthConfig
|
||||
}
|
||||
|
||||
const workbenchEventPublishTimeout = 15 * time.Second
|
||||
@@ -30,11 +32,11 @@ const (
|
||||
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 {
|
||||
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) {
|
||||
@@ -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/executive-mailbox/config", a.withAuth(a.mailboxConfig))
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user