500 lines
20 KiB
Go
500 lines
20 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"html"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"gpt-load/internal/channel"
|
|
app_errors "gpt-load/internal/platform/errors"
|
|
"gpt-load/internal/storage/models"
|
|
subscriptionruntime "gpt-load/internal/subscription/runtime"
|
|
)
|
|
|
|
const defaultOAuthCallbackHost = "127.0.0.1"
|
|
|
|
const oauthResultScript = `function closeThisWindow() {
|
|
if (window.opener && !window.opener.closed) window.opener.focus()
|
|
window.close()
|
|
window.setTimeout(() => {
|
|
const hint = document.getElementById("close-hint")
|
|
const button = document.getElementById("close-and-return")
|
|
if (hint) hint.hidden = false
|
|
if (button) {
|
|
button.disabled = true
|
|
button.textContent = "请手动关闭此页面"
|
|
}
|
|
}, 100)
|
|
}
|
|
const closeButton = document.getElementById("close-and-return")
|
|
if (closeButton) closeButton.addEventListener("click", closeThisWindow)
|
|
if (document.body.dataset.autoclose === "1") {
|
|
window.setTimeout(closeThisWindow, 2000)
|
|
}`
|
|
|
|
type oauthCallbackParameters struct {
|
|
State string
|
|
Code string
|
|
ProviderError string
|
|
}
|
|
|
|
type oauthCallbackListener struct {
|
|
server *http.Server
|
|
listener net.Listener
|
|
}
|
|
|
|
// OAuthCallbackManager owns the restricted localhost callbacks requested by
|
|
// compiled subscription drivers. They remain deliberately separate from the
|
|
// authenticated /api server.
|
|
type OAuthCallbackManager struct {
|
|
service *Service
|
|
host string
|
|
listen func(string, string) (net.Listener, error)
|
|
|
|
mu sync.Mutex
|
|
listeners map[string]oauthCallbackListener
|
|
}
|
|
|
|
func NewOAuthCallbackManager(service *Service) *OAuthCallbackManager {
|
|
return &OAuthCallbackManager{
|
|
service: service,
|
|
host: defaultOAuthCallbackHost,
|
|
listen: net.Listen,
|
|
listeners: make(map[string]oauthCallbackListener),
|
|
}
|
|
}
|
|
|
|
func (manager *OAuthCallbackManager) configureForServerHost(host string) {
|
|
if manager == nil || (host != "0.0.0.0" && host != "::") {
|
|
return
|
|
}
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if len(manager.listeners) == 0 && manager.host == defaultOAuthCallbackHost {
|
|
manager.host = host
|
|
}
|
|
}
|
|
|
|
func (manager *OAuthCallbackManager) EnsureStarted(spec subscriptionruntime.LocalCallbackSpec) error {
|
|
if manager == nil || manager.service == nil {
|
|
return errors.New("OAuth callback manager is unavailable")
|
|
}
|
|
port, path, err := parseLocalCallbackEndpoint(spec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if _, exists := manager.listeners[spec.RedirectURI]; exists {
|
|
return nil
|
|
}
|
|
listener, err := manager.listen("tcp", net.JoinHostPort(manager.host, port))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
httpServer := &http.Server{
|
|
Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
manager.serveHTTP(spec, path, writer, request)
|
|
}),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
IdleTimeout: 30 * time.Second,
|
|
MaxHeaderBytes: 32 * 1024,
|
|
ErrorLog: log.New(io.Discard, "", 0),
|
|
}
|
|
manager.listeners[spec.RedirectURI] = oauthCallbackListener{server: httpServer, listener: listener}
|
|
go func() {
|
|
if serveErr := httpServer.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
|
logrus.WithFields(logrus.Fields{
|
|
"event": "oauth.callback_serve_failed",
|
|
"redirect_uri": spec.RedirectURI,
|
|
}).WithError(serveErr).Error("OAuth callback listener stopped")
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (manager *OAuthCallbackManager) Addr(spec subscriptionruntime.LocalCallbackSpec) string {
|
|
if manager == nil {
|
|
return ""
|
|
}
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
listener, ok := manager.listeners[spec.RedirectURI]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return listener.listener.Addr().String()
|
|
}
|
|
|
|
func (manager *OAuthCallbackManager) Stop(ctx context.Context) error {
|
|
if manager == nil {
|
|
return nil
|
|
}
|
|
manager.mu.Lock()
|
|
listeners := manager.listeners
|
|
manager.listeners = make(map[string]oauthCallbackListener)
|
|
manager.mu.Unlock()
|
|
var shutdownErrors []error
|
|
for _, listener := range listeners {
|
|
if err := listener.server.Shutdown(ctx); err != nil {
|
|
shutdownErrors = append(shutdownErrors, err)
|
|
}
|
|
}
|
|
return errors.Join(shutdownErrors...)
|
|
}
|
|
|
|
func (manager *OAuthCallbackManager) Run(ctx context.Context) {
|
|
if manager == nil || manager.service == nil {
|
|
return
|
|
}
|
|
var pendingChannelIDs []string
|
|
nowMS := manager.service.now().UnixMilli()
|
|
if err := manager.service.db.WithContext(ctx).Model(&models.CredentialStage{}).
|
|
Where("status = ? AND expires_at_ms > ?", models.CredentialStagePendingAuthorization, nowMS).
|
|
Distinct("channel_id").Order("channel_id ASC").Pluck("channel_id", &pendingChannelIDs).Error; err == nil {
|
|
for _, rawChannelID := range pendingChannelIDs {
|
|
callback, ok := manager.service.subscriptions.LocalCallback(channel.ID(rawChannelID))
|
|
if !ok {
|
|
continue
|
|
}
|
|
if startErr := manager.EnsureStarted(callback); startErr != nil {
|
|
logrus.WithFields(logrus.Fields{
|
|
"event": "oauth.callback_start_failed",
|
|
"redirect_uri": callback.RedirectURI,
|
|
}).WithError(startErr).Warn("OAuth callback listener is unavailable")
|
|
}
|
|
}
|
|
}
|
|
<-ctx.Done()
|
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
_ = manager.Stop(shutdownContext)
|
|
}
|
|
|
|
func (manager *OAuthCallbackManager) serveHTTP(
|
|
spec subscriptionruntime.LocalCallbackSpec,
|
|
path string,
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
setOAuthCallbackHeaders(writer)
|
|
if request.Method != http.MethodGet {
|
|
writer.Header().Set("Allow", http.MethodGet)
|
|
http.Error(writer, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if request.URL.Path != path {
|
|
http.NotFound(writer, request)
|
|
return
|
|
}
|
|
callback, err := parseOAuthCallbackQuery(request.URL.Query())
|
|
if err != nil {
|
|
writeOAuthResult(writer, oauthCallbackOutcome{Kind: oauthOutcomeInvalid})
|
|
return
|
|
}
|
|
if callback.ProviderError != "" {
|
|
if err := manager.service.failCredentialAuthorizationFromCallback(
|
|
request.Context(),
|
|
spec,
|
|
callback.State,
|
|
callback.ProviderError,
|
|
); err != nil {
|
|
writeOAuthResult(writer, oauthCallbackOutcome{Kind: classifyOAuthCallbackFailure(err)})
|
|
return
|
|
}
|
|
writeOAuthResult(writer, oauthCallbackOutcome{Kind: oauthOutcomeDenied})
|
|
return
|
|
}
|
|
result, err := manager.service.completeCredentialAuthorizationFromCallback(
|
|
request.Context(),
|
|
spec,
|
|
callback.State,
|
|
callback.Code,
|
|
)
|
|
if err != nil {
|
|
writeOAuthResult(writer, oauthCallbackOutcome{
|
|
Kind: classifyOAuthCallbackFailure(err),
|
|
})
|
|
return
|
|
}
|
|
writeOAuthResult(writer, oauthCallbackOutcome{Kind: oauthOutcomeSuccess, AccountMask: result.Account.EmailMask})
|
|
}
|
|
|
|
// classifyOAuthCallbackFailure maps the completion error to a user-facing
|
|
// outcome. Anything not explicitly recognized is treated as an exchange
|
|
// failure, the only bucket that is honest without knowing more.
|
|
func classifyOAuthCallbackFailure(err error) oauthCallbackOutcomeKind {
|
|
switch {
|
|
case errors.Is(err, app_errors.ErrStagedCredentialExpired):
|
|
return oauthOutcomeExpired
|
|
case errors.Is(err, app_errors.ErrAuthorizationStateInvalid):
|
|
return oauthOutcomeInvalid
|
|
default:
|
|
return oauthOutcomeExchangeFailed
|
|
}
|
|
}
|
|
|
|
func parseLocalCallbackEndpoint(spec subscriptionruntime.LocalCallbackSpec) (string, string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(spec.RedirectURI))
|
|
if err != nil || !parsed.IsAbs() || parsed.Opaque != "" || parsed.User != nil || parsed.Fragment != "" ||
|
|
parsed.RawQuery != "" || parsed.ForceQuery || !strings.EqualFold(parsed.Scheme, "http") ||
|
|
!strings.EqualFold(parsed.Hostname(), "localhost") || parsed.Port() == "" ||
|
|
parsed.Path == "" || parsed.EscapedPath() != parsed.Path {
|
|
return "", "", errors.New("invalid OAuth callback endpoint")
|
|
}
|
|
canonical := "http://localhost:" + parsed.Port() + parsed.Path
|
|
if spec.RedirectURI != canonical {
|
|
return "", "", errors.New("invalid OAuth callback endpoint")
|
|
}
|
|
return parsed.Port(), parsed.Path, nil
|
|
}
|
|
|
|
func parseManualOAuthCallbackURL(raw string, spec subscriptionruntime.LocalCallbackSpec) (oauthCallbackParameters, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || len(raw) > 16*1024 {
|
|
return oauthCallbackParameters{}, errors.New("invalid OAuth callback URL")
|
|
}
|
|
expected, err := url.Parse(spec.RedirectURI)
|
|
if err != nil {
|
|
return oauthCallbackParameters{}, errors.New("invalid OAuth callback URL")
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "http") ||
|
|
parsed.User != nil || parsed.Fragment != "" || !strings.EqualFold(parsed.Hostname(), "localhost") ||
|
|
parsed.Port() != expected.Port() || parsed.EscapedPath() != expected.EscapedPath() {
|
|
return oauthCallbackParameters{}, errors.New("invalid OAuth callback URL")
|
|
}
|
|
return parseOAuthCallbackQuery(parsed.Query())
|
|
}
|
|
|
|
func parseOAuthCallbackQuery(query url.Values) (oauthCallbackParameters, error) {
|
|
stateValues, codeValues, errorValues := query["state"], query["code"], query["error"]
|
|
if len(stateValues) != 1 || strings.TrimSpace(stateValues[0]) == "" {
|
|
return oauthCallbackParameters{}, errors.New("invalid OAuth callback state")
|
|
}
|
|
if len(errorValues) == 1 && strings.TrimSpace(errorValues[0]) != "" && len(codeValues) == 0 {
|
|
if descriptions := query["error_description"]; len(descriptions) > 1 {
|
|
return oauthCallbackParameters{}, errors.New("invalid OAuth callback error")
|
|
}
|
|
return oauthCallbackParameters{State: stateValues[0], ProviderError: errorValues[0]}, nil
|
|
}
|
|
if len(codeValues) != 1 || strings.TrimSpace(codeValues[0]) == "" || len(errorValues) != 0 || len(query["error_description"]) != 0 {
|
|
return oauthCallbackParameters{}, errors.New("invalid OAuth callback code")
|
|
}
|
|
return oauthCallbackParameters{State: stateValues[0], Code: codeValues[0]}, nil
|
|
}
|
|
|
|
func setOAuthCallbackHeaders(writer http.ResponseWriter) {
|
|
scriptHash := sha256.Sum256([]byte(oauthResultScript))
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
writer.Header().Set("Pragma", "no-cache")
|
|
writer.Header().Set("Referrer-Policy", "no-referrer")
|
|
writer.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'sha256-"+base64.StdEncoding.EncodeToString(scriptHash[:])+"'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
|
|
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
|
}
|
|
|
|
type oauthCallbackOutcomeKind string
|
|
|
|
const (
|
|
oauthOutcomeSuccess oauthCallbackOutcomeKind = "success"
|
|
oauthOutcomeDenied oauthCallbackOutcomeKind = "denied"
|
|
oauthOutcomeInvalid oauthCallbackOutcomeKind = "invalid"
|
|
oauthOutcomeExpired oauthCallbackOutcomeKind = "expired"
|
|
oauthOutcomeExchangeFailed oauthCallbackOutcomeKind = "exchange_failed"
|
|
)
|
|
|
|
// oauthCallbackOutcome carries only what the result page needs to render.
|
|
// AccountMask is only ever set alongside oauthOutcomeSuccess.
|
|
type oauthCallbackOutcome struct {
|
|
Kind oauthCallbackOutcomeKind
|
|
AccountMask string
|
|
}
|
|
|
|
type oauthCallbackPresentation struct {
|
|
Title string
|
|
Message string
|
|
Status string
|
|
Tone string
|
|
Icon string
|
|
AutoClose bool
|
|
}
|
|
|
|
func presentOAuthCallbackOutcome(outcome oauthCallbackOutcome) oauthCallbackPresentation {
|
|
switch outcome.Kind {
|
|
case oauthOutcomeSuccess:
|
|
return oauthCallbackPresentation{
|
|
Title: "授权已完成",
|
|
Message: "授权信息已安全返回 GPT-Load。请返回 GPT-Load 添加账号,这个页面可以关闭了。",
|
|
Status: "已完成",
|
|
Tone: "success",
|
|
Icon: `<path d="m6.5 12.5 3.5 3.5 7.5-8"/>`,
|
|
AutoClose: true,
|
|
}
|
|
case oauthOutcomeDenied:
|
|
return oauthCallbackPresentation{
|
|
Title: "授权未完成",
|
|
Message: "上游账号服务没有完成这次授权。回到 GPT-Load 重新开始一次即可。",
|
|
Status: "已取消",
|
|
Tone: "warning",
|
|
Icon: oauthWarningIconPath,
|
|
}
|
|
case oauthOutcomeExpired:
|
|
return oauthCallbackPresentation{
|
|
Title: "授权会话已过期",
|
|
Message: "这次授权花的时间超过了有效期。回到 GPT-Load 重新开始一次即可,之前的账号不受影响。",
|
|
Status: "已过期",
|
|
Tone: "warning",
|
|
Icon: oauthWarningIconPath,
|
|
}
|
|
case oauthOutcomeExchangeFailed:
|
|
return oauthCallbackPresentation{
|
|
Title: "换取凭据时失败",
|
|
Message: "GPT-Load 未能确认凭据交换结果。请回到 GPT-Load 查看状态,并重新发起授权;本页面的回调地址不能重复使用。",
|
|
Status: "需要处理",
|
|
Tone: "danger",
|
|
Icon: `<path d="M8 8l8 8M16 8l-8 8"/>`,
|
|
}
|
|
default:
|
|
return oauthCallbackPresentation{
|
|
Title: "无法识别这次授权",
|
|
Message: "这个授权请求的信息不完整或已失效。回到 GPT-Load 重新开始一次即可。",
|
|
Status: "无效请求",
|
|
Tone: "warning",
|
|
Icon: oauthWarningIconPath,
|
|
}
|
|
}
|
|
}
|
|
|
|
const oauthWarningIconPath = `<path d="M12 7.5v6"/><path d="M12 16.8v.1"/>`
|
|
|
|
const oauthBrandMarkSVG = `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><g transform="rotate(45 12 12)"><path d="M4 6.6H8V16H14.4V20H4Z"/><path d="M20 17.4H16V8H9.6V4H20Z"/></g></svg>`
|
|
|
|
const oauthResultStyle = `:root {
|
|
color-scheme: light dark;
|
|
--canvas: #f3f5f7; --surface: #ffffff; --sunken: #f7f8f9; --text: #17202a; --muted: #4f5d6a;
|
|
--faint: #667481; --border-subtle: #dfe4e8; --border-control: #c7d0d8; --action: #165d7a;
|
|
--action-hover: #104a63; --action-ink: #ffffff; --tone: #667481; --tone-bg: #eef1f3;
|
|
}
|
|
body[data-tone="success"] { --tone: #18724a; --tone-bg: #e8f5ee; }
|
|
body[data-tone="warning"] { --tone: #8a5d0b; --tone-bg: #fbf2dc; }
|
|
body[data-tone="danger"] { --tone: #b83232; --tone-bg: #fcebea; }
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--canvas: #0c1015; --surface: #151a21; --sunken: #10151b; --text: #eef1f3; --muted: #abb4bd;
|
|
--faint: #909ba6; --border-subtle: #2a323b; --border-control: #3d4853; --action: #78bddb;
|
|
--action-hover: #91cce4; --action-ink: #0d1d25; --tone: #aab3bc; --tone-bg: #242b33;
|
|
}
|
|
body[data-tone="success"] { --tone: #62c38d; --tone-bg: #142c20; }
|
|
body[data-tone="warning"] { --tone: #e0b45b; --tone-bg: #302610; }
|
|
body[data-tone="danger"] { --tone: #ee7d7d; --tone-bg: #351918; }
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; background: var(--canvas); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 16px; }
|
|
button { font: inherit; }
|
|
.shell { width: min(100%, 40rem); padding: clamp(1rem, 4vw, 2rem); }
|
|
.card { position: relative; overflow: hidden; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 18px; }
|
|
.card::before { position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--tone); content: ""; }
|
|
.card__header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 4rem; padding: 1rem clamp(1.25rem, 5vw, 2rem); border-bottom: 1px solid var(--border-subtle); }
|
|
.brand { display: inline-flex; align-items: center; gap: .5rem; color: var(--text); font-size: .78rem; font-weight: 750; letter-spacing: .075em; }
|
|
.brand svg { width: 18px; height: 18px; fill: var(--action); }
|
|
.result-badge { display: inline-flex; align-items: center; min-height: 1.75rem; padding: .25rem .65rem; border-radius: 999px; background: var(--tone-bg); color: var(--tone); font-size: .78rem; font-weight: 700; white-space: nowrap; }
|
|
.card__body { display: grid; gap: 1.5rem; padding: clamp(1.5rem, 6vw, 2.5rem) clamp(1.25rem, 5vw, 2rem); }
|
|
.result-summary { display: grid; grid-template-columns: 3.5rem minmax(0, 1fr); align-items: start; gap: 1.15rem; }
|
|
.status-icon { display: grid; place-items: center; width: 3.5rem; height: 3.5rem; border-radius: 14px; background: var(--tone-bg); color: var(--tone); }
|
|
.status-icon svg { width: 1.65rem; height: 1.65rem; fill: none; stroke: currentColor; stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; }
|
|
.result-copy { min-width: 0; }
|
|
.eyebrow { margin: 0 0 .35rem; color: var(--tone); font-size: .76rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
|
h1 { margin: 0; font-size: clamp(1.45rem, 5vw, 1.85rem); line-height: 1.25; letter-spacing: -.025em; }
|
|
.message { max-width: 52ch; margin: .65rem 0 0; color: var(--muted); font-size: .98rem; line-height: 1.7; }
|
|
.account-chip { display: flex; flex-wrap: wrap; align-items: center; gap: .55rem; padding: .8rem 1rem; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--sunken); }
|
|
.account-chip span { color: var(--faint); font-size: .8rem; font-weight: 650; }
|
|
.account-chip code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: .86rem; overflow-wrap: anywhere; }
|
|
.close-button { min-height: 2.75rem; border-radius: 9px; font-weight: 700; cursor: pointer; transition: background-color .18s ease, color .18s ease; }
|
|
.close-button:focus-visible { outline: 3px solid var(--action); outline-offset: 3px; }
|
|
.actions { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1.25rem, 5vw, 2rem); border-top: 1px solid var(--border-subtle); background: var(--sunken); }
|
|
.action-note { min-width: 0; }
|
|
.auto-close-note, .close-hint { margin: 0; color: var(--faint); font-size: .82rem; line-height: 1.55; }
|
|
.close-button { flex: 0 0 auto; min-width: 9.5rem; padding: .65rem 1.2rem; border: 0; color: var(--action-ink); background: var(--action); }
|
|
.close-button:hover { background: var(--action-hover); }
|
|
.close-button:disabled { cursor: default; background: var(--faint); color: var(--surface); }
|
|
[hidden] { display: none; }
|
|
@media (max-width: 32rem) {
|
|
.shell { padding: .75rem; }
|
|
.card { border-radius: 14px; }
|
|
.card__header, .card__body, .actions { padding-left: 1.15rem; padding-right: 1.15rem; }
|
|
.result-summary { grid-template-columns: 1fr; }
|
|
.actions { align-items: stretch; flex-direction: column; }
|
|
.close-button { width: 100%; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) { .close-button { transition: none; } }`
|
|
|
|
func writeOAuthResult(writer http.ResponseWriter, outcome oauthCallbackOutcome) {
|
|
presentation := presentOAuthCallbackOutcome(outcome)
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.Header().Set("Content-Language", "zh-CN")
|
|
writer.WriteHeader(http.StatusOK)
|
|
autoCloseAttr := "0"
|
|
autoCloseNote := `<p class="auto-close-note">处理完成后可安全关闭此页面。</p>`
|
|
if presentation.AutoClose {
|
|
autoCloseAttr = "1"
|
|
autoCloseNote = `<p class="auto-close-note">页面将在 2 秒后自动关闭。</p>`
|
|
}
|
|
accountChip := ""
|
|
if outcome.Kind == oauthOutcomeSuccess && outcome.AccountMask != "" {
|
|
accountChip = `<div class="account-chip"><span>已授权账号</span><code>` + html.EscapeString(outcome.AccountMask) + `</code></div>`
|
|
}
|
|
title := html.EscapeString(presentation.Title)
|
|
status := html.EscapeString(presentation.Status)
|
|
page := `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>` + title + `</title>
|
|
<style>` + oauthResultStyle + `</style>
|
|
</head>
|
|
<body data-tone="` + presentation.Tone + `" data-autoclose="` + autoCloseAttr + `">
|
|
<main class="shell">
|
|
<section class="card" aria-labelledby="result-title" aria-describedby="result-message">
|
|
<header class="card__header">
|
|
<span class="brand">` + oauthBrandMarkSVG + `GPT-LOAD</span>
|
|
<span class="result-badge">` + status + `</span>
|
|
</header>
|
|
<div class="card__body">
|
|
<div class="result-summary">
|
|
<div class="status-icon"><svg viewBox="0 0 24 24" aria-hidden="true">` + presentation.Icon + `</svg></div>
|
|
<div class="result-copy">
|
|
<p class="eyebrow">订阅账号授权</p>
|
|
<h1 id="result-title">` + title + `</h1>
|
|
<p id="result-message" class="message">` + html.EscapeString(presentation.Message) + `</p>
|
|
</div>
|
|
</div>
|
|
` + accountChip + `
|
|
</div>
|
|
<footer class="actions">
|
|
<div class="action-note">
|
|
` + autoCloseNote + `
|
|
<p id="close-hint" class="close-hint" role="status" aria-live="polite" hidden>浏览器未允许自动关闭,请手动关闭此页面。</p>
|
|
</div>
|
|
<button id="close-and-return" class="close-button" type="button">关闭并返回</button>
|
|
</footer>
|
|
</section>
|
|
</main>
|
|
<script>` + oauthResultScript + `</script>
|
|
</body>
|
|
</html>`
|
|
_, _ = io.WriteString(writer, page)
|
|
}
|