679 lines
20 KiB
Go
679 lines
20 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config"
|
|
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/db"
|
|
"github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/service"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var adminTemplateFS embed.FS
|
|
|
|
const (
|
|
adminSessionCookie = "smilefirst_admin"
|
|
adminOIDCCookie = "smilefirst_oidc"
|
|
defaultAdminPage = "/admin/price-inquiries"
|
|
adminUserLocalKey = "adminUser"
|
|
defaultAdminPageSize = 50
|
|
)
|
|
|
|
type adminUI struct {
|
|
cfg config.Config
|
|
svc *service.Service
|
|
templates *template.Template
|
|
oidcMu sync.Mutex
|
|
oidcProvider *oidc.Provider
|
|
oauth2Config *oauth2.Config
|
|
oidcVerifier *oidc.IDTokenVerifier
|
|
}
|
|
|
|
type adminPageData struct {
|
|
PageTitle string
|
|
Active string
|
|
Username string
|
|
Next string
|
|
Message string
|
|
Error string
|
|
OIDCLoginURL string
|
|
PasswordChangeURL string
|
|
HomeHeroImage string
|
|
Categories []service.CategoryWithProjects
|
|
PhoneRecords []service.AdminPhoneRecord
|
|
BookingRecords []service.AdminBookingRecord
|
|
PriceInquiryRecords []service.AdminPriceInquiryRecord
|
|
PhoneFilter string
|
|
AreaFilter string
|
|
StatusFilter string
|
|
TimeFromFilter string
|
|
TimeToFilter string
|
|
HasPriceInquiryFilter bool
|
|
PriceInquiryTotal int64
|
|
PriceInquiryPage int
|
|
PriceInquiryPageSize int
|
|
PriceInquiryTotalPages int
|
|
PriceInquiryHasPreviousPage bool
|
|
PriceInquiryHasNextPage bool
|
|
PriceInquiryPreviousURL string
|
|
PriceInquiryNextURL string
|
|
PriceInquiryPageSizeOptions []adminPageSizeOption
|
|
}
|
|
|
|
type adminPageSizeOption struct {
|
|
Value int
|
|
Selected bool
|
|
}
|
|
|
|
func newAdminUI(cfg config.Config, svc *service.Service) *adminUI {
|
|
tmpl := template.Must(template.ParseFS(adminTemplateFS, "templates/*.html"))
|
|
return &adminUI{cfg: cfg, svc: svc, templates: tmpl}
|
|
}
|
|
|
|
func (a *adminUI) registerPublic(app *fiber.App) {
|
|
app.Get("/admin/login", a.loginPage)
|
|
app.Post("/admin/login", a.loginRedirect)
|
|
app.Get("/admin/oidc/start", a.oidcStart)
|
|
app.Get("/admin/oidc/callback", a.oidcCallback)
|
|
app.Post("/admin/logout", a.logout)
|
|
}
|
|
|
|
func (a *adminUI) registerPages(admin fiber.Router) {
|
|
admin.Get("/", func(c fiber.Ctx) error {
|
|
return c.Redirect().To(defaultAdminPage)
|
|
})
|
|
admin.Get("/price-inquiries", a.priceInquiriesPage)
|
|
admin.Post("/price-inquiries/settings", a.savePriceInquirySettings)
|
|
}
|
|
|
|
func (a *adminUI) requireLogin(c fiber.Ctx) error {
|
|
if a.cfg.AdminAuthDisabled() {
|
|
c.Locals(adminUserLocalKey, adminUser{
|
|
Subject: "auth:none",
|
|
DisplayName: "免登录管理员",
|
|
})
|
|
return c.Next()
|
|
}
|
|
if user, ok := a.validSession(c.Cookies(adminSessionCookie)); ok {
|
|
c.Locals(adminUserLocalKey, user)
|
|
return c.Next()
|
|
}
|
|
if c.Method() == fiber.MethodGet && wantsHTML(c) {
|
|
next := url.QueryEscape(c.OriginalURL())
|
|
return c.Redirect().To("/admin/login?next=" + next)
|
|
}
|
|
return fiber.NewError(fiber.StatusUnauthorized, "admin login required")
|
|
}
|
|
|
|
func (a *adminUI) loginPage(c fiber.Ctx) error {
|
|
if a.cfg.AdminAuthDisabled() {
|
|
return c.Redirect().To(defaultAdminPage)
|
|
}
|
|
if _, ok := a.validSession(c.Cookies(adminSessionCookie)); ok {
|
|
return c.Redirect().To(safeAdminNext(c.Query("next")))
|
|
}
|
|
next := safeAdminNext(c.Query("next"))
|
|
return a.render(c, "login.html", adminPageData{
|
|
PageTitle: "后台登录",
|
|
Next: next,
|
|
Error: c.Query("error"),
|
|
OIDCLoginURL: "/admin/oidc/start?next=" + url.QueryEscape(next),
|
|
})
|
|
}
|
|
|
|
func (a *adminUI) loginRedirect(c fiber.Ctx) error {
|
|
if a.cfg.AdminAuthDisabled() {
|
|
return c.Redirect().To(defaultAdminPage)
|
|
}
|
|
next := safeAdminNext(c.FormValue("next"))
|
|
return c.Redirect().To("/admin/oidc/start?next=" + url.QueryEscape(next))
|
|
}
|
|
|
|
func (a *adminUI) oidcStart(c fiber.Ctx) error {
|
|
if !a.cfg.AdminOIDCLoginEnabled() {
|
|
return c.Redirect().To(defaultAdminPage)
|
|
}
|
|
oauthConfig, _, _, err := a.oidcClient(c.Context())
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
state := adminOIDCState{
|
|
State: randomHex(24),
|
|
Nonce: randomHex(24),
|
|
Verifier: oauth2.GenerateVerifier(),
|
|
Next: safeAdminNext(c.Query("next")),
|
|
Expires: time.Now().Add(10 * time.Minute).Unix(),
|
|
}
|
|
expires := time.Unix(state.Expires, 0)
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: adminOIDCCookie,
|
|
Value: a.signCookieValue(state),
|
|
Path: "/admin",
|
|
MaxAge: int(time.Until(expires).Seconds()),
|
|
Expires: expires,
|
|
HTTPOnly: true,
|
|
SameSite: "Lax",
|
|
})
|
|
return c.Redirect().To(oauthConfig.AuthCodeURL(
|
|
state.State,
|
|
oauth2.AccessTypeOffline,
|
|
oauth2.S256ChallengeOption(state.Verifier),
|
|
oidc.Nonce(state.Nonce),
|
|
))
|
|
}
|
|
|
|
func (a *adminUI) oidcCallback(c fiber.Ctx) error {
|
|
if !a.cfg.AdminOIDCLoginEnabled() {
|
|
return c.Redirect().To(defaultAdminPage)
|
|
}
|
|
state, ok := a.validOIDCState(c.Cookies(adminOIDCCookie))
|
|
a.clearOIDCStateCookie(c)
|
|
if !ok || !constantTimeEqual(state.State, c.Query("state")) {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权状态已失效,请重新登录"))
|
|
}
|
|
if errText := strings.TrimSpace(c.Query("error")); errText != "" {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape(errText))
|
|
}
|
|
code := strings.TrimSpace(c.Query("code"))
|
|
if code == "" {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权回调缺少 code"))
|
|
}
|
|
oauthConfig, provider, verifier, err := a.oidcClient(c.Context())
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
token, err := oauthConfig.Exchange(c.Context(), code, oauth2.VerifierOption(state.Verifier))
|
|
if err != nil {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权登录失败,请重试"))
|
|
}
|
|
rawIDToken, ok := token.Extra("id_token").(string)
|
|
if !ok || rawIDToken == "" {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权结果缺少 ID Token"))
|
|
}
|
|
idToken, err := verifier.Verify(c.Context(), rawIDToken)
|
|
if err != nil {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权身份校验失败"))
|
|
}
|
|
if !constantTimeEqual(idToken.Nonce, state.Nonce) {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权 nonce 校验失败"))
|
|
}
|
|
user, err := a.adminUserFromToken(c.Context(), provider, token, idToken)
|
|
if err != nil {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权用户信息读取失败"))
|
|
}
|
|
expires := time.Now().Add(8 * time.Hour)
|
|
if idToken.Expiry.Before(expires) {
|
|
expires = idToken.Expiry
|
|
}
|
|
if time.Until(expires) <= 0 {
|
|
return c.Redirect().To("/admin/login?error=" + url.QueryEscape("授权身份已过期,请重新登录"))
|
|
}
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: adminSessionCookie,
|
|
Value: a.signSession(user, expires),
|
|
Path: "/admin",
|
|
MaxAge: int(time.Until(expires).Seconds()),
|
|
Expires: expires,
|
|
HTTPOnly: true,
|
|
SameSite: "Lax",
|
|
})
|
|
return c.Redirect().To(state.Next)
|
|
}
|
|
|
|
func (a *adminUI) logout(c fiber.Ctx) error {
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: adminSessionCookie,
|
|
Value: "",
|
|
Path: "/admin",
|
|
MaxAge: -1,
|
|
Expires: time.Now().Add(-time.Hour),
|
|
HTTPOnly: true,
|
|
SameSite: "Lax",
|
|
})
|
|
return c.Redirect().To("/admin/login")
|
|
}
|
|
|
|
func (a *adminUI) priceInquiriesPage(c fiber.Ctx) error {
|
|
filter := service.AdminPriceInquiryFilter{
|
|
Area: strings.TrimSpace(c.Query("area")),
|
|
Phone: strings.TrimSpace(c.Query("phone")),
|
|
Status: strings.TrimSpace(c.Query("status")),
|
|
TimeFrom: strings.TrimSpace(c.Query("time_from")),
|
|
TimeTo: strings.TrimSpace(c.Query("time_to")),
|
|
}
|
|
if !validAdminPriceInquiryStatusFilter(filter.Status) {
|
|
return fiber.NewError(fiber.StatusBadRequest, "咨询状态筛选无效")
|
|
}
|
|
page, pageSize, err := parseAdminPriceInquiryQueryPagination(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result, err := a.svc.AdminPriceInquiryRecordsPage(c.Context(), filter, page, pageSize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.render(c, "price_inquiries.html", adminPageData{
|
|
PageTitle: "价格咨询派单",
|
|
Active: "price-inquiries",
|
|
PriceInquiryRecords: result.Records,
|
|
AreaFilter: filter.Area,
|
|
PhoneFilter: filter.Phone,
|
|
StatusFilter: filter.Status,
|
|
TimeFromFilter: filter.TimeFrom,
|
|
TimeToFilter: filter.TimeTo,
|
|
HasPriceInquiryFilter: filter.Area != "" || filter.Phone != "" || filter.Status != "" || filter.TimeFrom != "" || filter.TimeTo != "",
|
|
PriceInquiryTotal: result.Total,
|
|
PriceInquiryPage: result.Page,
|
|
PriceInquiryPageSize: result.PageSize,
|
|
PriceInquiryTotalPages: result.TotalPages,
|
|
PriceInquiryHasPreviousPage: result.HasPreviousPage,
|
|
PriceInquiryHasNextPage: result.HasNextPage,
|
|
PriceInquiryPreviousURL: priceInquiriesURL(filter, result.PreviousPage, result.PageSize, "", ""),
|
|
PriceInquiryNextURL: priceInquiriesURL(filter, result.NextPage, result.PageSize, "", ""),
|
|
PriceInquiryPageSizeOptions: adminPageSizeOptions(result.PageSize),
|
|
Message: c.Query("message"),
|
|
Error: c.Query("error"),
|
|
})
|
|
}
|
|
|
|
func (a *adminUI) savePriceInquirySettings(c fiber.Ctx) error {
|
|
id := strings.TrimSpace(c.FormValue("id"))
|
|
status := strings.TrimSpace(c.FormValue("status"))
|
|
filter := service.AdminPriceInquiryFilter{
|
|
Area: strings.TrimSpace(c.FormValue("area")),
|
|
Phone: strings.TrimSpace(c.FormValue("phone")),
|
|
Status: strings.TrimSpace(c.FormValue("status_filter")),
|
|
TimeFrom: strings.TrimSpace(c.FormValue("time_from")),
|
|
TimeTo: strings.TrimSpace(c.FormValue("time_to")),
|
|
}
|
|
page, pageSize, err := parseAdminPriceInquiryFormPagination(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !validAdminPriceInquiryStatusFilter(filter.Status) {
|
|
return redirectPriceInquiries(c, service.AdminPriceInquiryFilter{}, page, pageSize, "error", "咨询状态筛选无效")
|
|
}
|
|
if id == "" {
|
|
return redirectPriceInquiries(c, filter, page, pageSize, "error", "咨询单不存在")
|
|
}
|
|
switch status {
|
|
case "pending", "assigned", "completed":
|
|
default:
|
|
return redirectPriceInquiries(c, filter, page, pageSize, "error", "咨询状态无效")
|
|
}
|
|
if _, err := a.svc.Q.UpdatePriceInquiry(c.Context(), db.UpdatePriceInquiryParams{
|
|
ID: id,
|
|
Status: status,
|
|
Remark: strings.TrimSpace(c.FormValue("remark")),
|
|
}); err != nil {
|
|
return redirectPriceInquiries(c, filter, page, pageSize, "error", fmt.Sprintf("咨询设置保存失败:%v", err))
|
|
}
|
|
return redirectPriceInquiries(c, filter, page, pageSize, "message", "咨询设置已保存")
|
|
}
|
|
|
|
func validAdminPriceInquiryStatusFilter(status string) bool {
|
|
switch status {
|
|
case "", "pending", "assigned", "completed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parseAdminPriceInquiryQueryPagination(c fiber.Ctx) (int, int, error) {
|
|
return parseAdminPriceInquiryPagination(c.Query("page"), c.Query("page_size"))
|
|
}
|
|
|
|
func parseAdminPriceInquiryFormPagination(c fiber.Ctx) (int, int, error) {
|
|
return parseAdminPriceInquiryPagination(c.FormValue("page"), c.FormValue("page_size"))
|
|
}
|
|
|
|
func parseAdminPriceInquiryPagination(pageRaw, pageSizeRaw string) (int, int, error) {
|
|
page, err := positiveIntOrDefault(pageRaw, 1)
|
|
if err != nil {
|
|
return 0, 0, fiber.NewError(fiber.StatusBadRequest, "分页页码无效")
|
|
}
|
|
pageSize, err := positiveIntOrDefault(pageSizeRaw, defaultAdminPageSize)
|
|
if err != nil {
|
|
return 0, 0, fiber.NewError(fiber.StatusBadRequest, "每页条数无效")
|
|
}
|
|
if !validAdminPageSize(pageSize) {
|
|
return 0, 0, fiber.NewError(fiber.StatusBadRequest, "每页条数无效")
|
|
}
|
|
return page, pageSize, nil
|
|
}
|
|
|
|
func positiveIntOrDefault(raw string, fallback int) (int, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return fallback, nil
|
|
}
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 1 {
|
|
return 0, fmt.Errorf("invalid positive integer %q", raw)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func validAdminPageSize(pageSize int) bool {
|
|
switch pageSize {
|
|
case 20, 50, 100, 200:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func adminPageSizeOptions(selected int) []adminPageSizeOption {
|
|
options := []int{20, 50, 100, 200}
|
|
result := make([]adminPageSizeOption, 0, len(options))
|
|
for _, option := range options {
|
|
result = append(result, adminPageSizeOption{
|
|
Value: option,
|
|
Selected: option == selected,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (a *adminUI) render(c fiber.Ctx, name string, data adminPageData) error {
|
|
if user, ok := currentAdminUser(c); ok {
|
|
data.Username = user.DisplayName
|
|
}
|
|
data.PasswordChangeURL = a.passwordChangeURL()
|
|
var buf bytes.Buffer
|
|
if err := a.templates.ExecuteTemplate(&buf, name, data); err != nil {
|
|
return err
|
|
}
|
|
c.Type("html", "utf-8")
|
|
return c.Send(buf.Bytes())
|
|
}
|
|
|
|
type adminUser struct {
|
|
Subject string `json:"sub"`
|
|
DisplayName string `json:"name"`
|
|
}
|
|
|
|
type adminSession struct {
|
|
Subject string `json:"sub"`
|
|
DisplayName string `json:"name"`
|
|
Expires int64 `json:"exp"`
|
|
}
|
|
|
|
type adminOIDCState struct {
|
|
State string `json:"state"`
|
|
Nonce string `json:"nonce"`
|
|
Verifier string `json:"verifier"`
|
|
Next string `json:"next"`
|
|
Expires int64 `json:"exp"`
|
|
}
|
|
|
|
type adminOIDCClaims struct {
|
|
Name string `json:"name"`
|
|
PreferredUsername string `json:"preferred_username"`
|
|
Email string `json:"email"`
|
|
PhoneNumber string `json:"phone_number"`
|
|
}
|
|
|
|
func (a *adminUI) signSession(user adminUser, expires time.Time) string {
|
|
return a.signCookieValue(adminSession{
|
|
Subject: user.Subject,
|
|
DisplayName: user.DisplayName,
|
|
Expires: expires.Unix(),
|
|
})
|
|
}
|
|
|
|
func (a *adminUI) validSession(token string) (adminUser, bool) {
|
|
var session adminSession
|
|
if !a.verifyCookieValue(token, &session) {
|
|
return adminUser{}, false
|
|
}
|
|
if strings.TrimSpace(session.Subject) == "" || time.Now().Unix() >= session.Expires {
|
|
return adminUser{}, false
|
|
}
|
|
displayName := strings.TrimSpace(session.DisplayName)
|
|
if displayName == "" {
|
|
displayName = session.Subject
|
|
}
|
|
return adminUser{Subject: session.Subject, DisplayName: displayName}, true
|
|
}
|
|
|
|
func (a *adminUI) validOIDCState(token string) (adminOIDCState, bool) {
|
|
var state adminOIDCState
|
|
if !a.verifyCookieValue(token, &state) {
|
|
return adminOIDCState{}, false
|
|
}
|
|
if strings.TrimSpace(state.State) == "" || strings.TrimSpace(state.Nonce) == "" || strings.TrimSpace(state.Verifier) == "" {
|
|
return adminOIDCState{}, false
|
|
}
|
|
if time.Now().Unix() >= state.Expires {
|
|
return adminOIDCState{}, false
|
|
}
|
|
state.Next = safeAdminNext(state.Next)
|
|
return state, true
|
|
}
|
|
|
|
func (a *adminUI) signCookieValue(value any) string {
|
|
payloadBytes, err := json.Marshal(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
|
sig := a.sessionMAC(payload)
|
|
return payload + "." + base64.RawURLEncoding.EncodeToString(sig)
|
|
}
|
|
|
|
func (a *adminUI) verifyCookieValue(token string, value any) bool {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if !hmac.Equal(signature, a.sessionMAC(parts[0])) {
|
|
return false
|
|
}
|
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return json.Unmarshal(payloadBytes, value) == nil
|
|
}
|
|
|
|
func (a *adminUI) sessionMAC(payload string) []byte {
|
|
secret := a.cfg.AdminSessionSecret
|
|
if secret == "" {
|
|
secret = a.cfg.OIDCClientSecret
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(payload))
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
func (a *adminUI) oidcClient(ctx context.Context) (*oauth2.Config, *oidc.Provider, *oidc.IDTokenVerifier, error) {
|
|
a.oidcMu.Lock()
|
|
defer a.oidcMu.Unlock()
|
|
if a.oauth2Config != nil && a.oidcProvider != nil && a.oidcVerifier != nil {
|
|
return a.oauth2Config, a.oidcProvider, a.oidcVerifier, nil
|
|
}
|
|
provider, err := oidc.NewProvider(ctx, a.cfg.OIDCIssuer)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("初始化 OIDC Provider 失败:%w", err)
|
|
}
|
|
oauthConfig := &oauth2.Config{
|
|
ClientID: a.cfg.OIDCClientID,
|
|
ClientSecret: a.cfg.OIDCClientSecret,
|
|
RedirectURL: a.cfg.OIDCRedirectURI,
|
|
Endpoint: provider.Endpoint(),
|
|
Scopes: oidcScopes(a.cfg.OIDCScopes),
|
|
}
|
|
verifier := provider.Verifier(&oidc.Config{ClientID: a.cfg.OIDCClientID})
|
|
a.oidcProvider = provider
|
|
a.oauth2Config = oauthConfig
|
|
a.oidcVerifier = verifier
|
|
return oauthConfig, provider, verifier, nil
|
|
}
|
|
|
|
func (a *adminUI) adminUserFromToken(ctx context.Context, provider *oidc.Provider, token *oauth2.Token, idToken *oidc.IDToken) (adminUser, error) {
|
|
claims := adminOIDCClaims{}
|
|
if err := idToken.Claims(&claims); err != nil {
|
|
return adminUser{}, err
|
|
}
|
|
userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
|
|
if err == nil {
|
|
_ = userInfo.Claims(&claims)
|
|
}
|
|
subject := strings.TrimSpace(idToken.Subject)
|
|
displayName := firstNonEmpty(claims.Name, claims.PreferredUsername, claims.Email, claims.PhoneNumber, subject)
|
|
if displayName == "" {
|
|
displayName = "管理员"
|
|
}
|
|
return adminUser{Subject: subject, DisplayName: displayName}, nil
|
|
}
|
|
|
|
func currentAdminUser(c fiber.Ctx) (adminUser, bool) {
|
|
value := c.Locals(adminUserLocalKey)
|
|
user, ok := value.(adminUser)
|
|
if !ok || strings.TrimSpace(user.DisplayName) == "" {
|
|
return adminUser{}, false
|
|
}
|
|
return user, true
|
|
}
|
|
|
|
func (a *adminUI) clearOIDCStateCookie(c fiber.Ctx) {
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: adminOIDCCookie,
|
|
Value: "",
|
|
Path: "/admin",
|
|
MaxAge: -1,
|
|
Expires: time.Now().Add(-time.Hour),
|
|
HTTPOnly: true,
|
|
SameSite: "Lax",
|
|
})
|
|
}
|
|
|
|
func oidcScopes(value string) []string {
|
|
seen := map[string]bool{"openid": true}
|
|
scopes := []string{"openid"}
|
|
parts := strings.FieldsFunc(value, func(r rune) bool {
|
|
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
|
})
|
|
for _, part := range parts {
|
|
scope := strings.TrimSpace(part)
|
|
if scope != "" && !seen[scope] {
|
|
scopes = append(scopes, scope)
|
|
seen[scope] = true
|
|
}
|
|
}
|
|
return scopes
|
|
}
|
|
|
|
func constantTimeEqual(a, b string) bool {
|
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|
|
|
|
func wantsHTML(c fiber.Ctx) bool {
|
|
accept := c.Get(fiber.HeaderAccept)
|
|
return accept == "" || strings.Contains(accept, "text/html")
|
|
}
|
|
|
|
func safeAdminNext(next string) string {
|
|
next = strings.TrimSpace(next)
|
|
if strings.HasPrefix(next, "/admin") && !strings.HasPrefix(next, "/admin/login") {
|
|
return next
|
|
}
|
|
return defaultAdminPage
|
|
}
|
|
|
|
func (a *adminUI) passwordChangeURL() string {
|
|
if !a.cfg.AdminOIDCLoginEnabled() {
|
|
return ""
|
|
}
|
|
issuer := strings.TrimSpace(a.cfg.OIDCIssuer)
|
|
redirectURL := strings.TrimSpace(a.cfg.AdminPasswordChangeRedirectURL)
|
|
if issuer == "" || redirectURL == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(issuer)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
return ""
|
|
}
|
|
u.RawQuery = ""
|
|
u.Fragment = ""
|
|
issuerPath := strings.TrimRight(u.Path, "/")
|
|
if strings.HasSuffix(issuerPath, "/oidc") {
|
|
issuerPath = strings.TrimSuffix(issuerPath, "/oidc")
|
|
}
|
|
u.Path = strings.TrimRight(issuerPath, "/") + "/account/password"
|
|
q := u.Query()
|
|
q.Set("redirect", redirectURL)
|
|
q.Set("show_success", "true")
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
func redirectPriceInquiries(c fiber.Ctx, filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) error {
|
|
return c.Redirect().To(priceInquiriesURL(filter, page, pageSize, key, message))
|
|
}
|
|
|
|
func priceInquiriesURL(filter service.AdminPriceInquiryFilter, page, pageSize int, key, message string) string {
|
|
query := url.Values{}
|
|
if key != "" {
|
|
query.Set(key, message)
|
|
}
|
|
if filter.Area != "" {
|
|
query.Set("area", filter.Area)
|
|
}
|
|
if filter.Phone != "" {
|
|
query.Set("phone", filter.Phone)
|
|
}
|
|
if filter.Status != "" {
|
|
query.Set("status", filter.Status)
|
|
}
|
|
if filter.TimeFrom != "" {
|
|
query.Set("time_from", filter.TimeFrom)
|
|
}
|
|
if filter.TimeTo != "" {
|
|
query.Set("time_to", filter.TimeTo)
|
|
}
|
|
if page > 1 {
|
|
query.Set("page", strconv.Itoa(page))
|
|
}
|
|
if pageSize != defaultAdminPageSize {
|
|
query.Set("page_size", strconv.Itoa(pageSize))
|
|
}
|
|
encoded := query.Encode()
|
|
if encoded == "" {
|
|
return "/admin/price-inquiries"
|
|
}
|
|
return "/admin/price-inquiries?" + encoded
|
|
}
|
|
func randomHex(size int) string {
|
|
buf := make([]byte, size)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|