mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
501 lines
17 KiB
Go
501 lines
17 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type basicAuthMemoryStore struct {
|
|
raw string
|
|
err error
|
|
}
|
|
|
|
func (m *basicAuthMemoryStore) GetSetting(string) (string, bool, error) {
|
|
return m.raw, m.raw != "", m.err
|
|
}
|
|
func (m *basicAuthMemoryStore) SetSettingContext(ctx context.Context, _ string, value string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
m.raw = value
|
|
return nil
|
|
}
|
|
|
|
func basicAuthTestServer(t *testing.T) (*Server, *basicAuthMemoryStore) {
|
|
t.Helper()
|
|
store := &basicAuthMemoryStore{}
|
|
s := &Server{jwtKey: []byte("test-only-signing-key-32-characters")}
|
|
if err := s.basicAuth.load(store); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s, store
|
|
}
|
|
|
|
func saveBasicAuthForTest(s *Server, body string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
s.saveBasicAuthSettings(w, httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body)))
|
|
return w
|
|
}
|
|
|
|
type blockedBasicAuthStore struct {
|
|
basicAuthSettingsStore
|
|
started chan basicAuthConfig
|
|
release chan struct{}
|
|
contexts chan context.Context
|
|
}
|
|
|
|
func (b *blockedBasicAuthStore) SetSettingContext(ctx context.Context, key, value string) error {
|
|
var cfg basicAuthConfig
|
|
if err := json.Unmarshal([]byte(value), &cfg); err != nil {
|
|
return err
|
|
}
|
|
b.started <- cfg
|
|
if b.contexts != nil {
|
|
b.contexts <- ctx
|
|
}
|
|
select {
|
|
case <-b.release:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
return b.basicAuthSettingsStore.SetSettingContext(ctx, key, value)
|
|
}
|
|
|
|
func TestBasicAuthBlockedSaveDoesNotBlockRequests(t *testing.T) {
|
|
for _, enabled := range []bool{false, true} {
|
|
name := "disabled"
|
|
if enabled {
|
|
name = "enabled"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
s, store := basicAuthTestServer(t)
|
|
body := `{"enabled":false,"username":"entry","password":"original-pass"}`
|
|
if enabled {
|
|
body = `{"enabled":true,"username":"entry","password":"original-pass"}`
|
|
}
|
|
initial := saveBasicAuthForTest(s, body)
|
|
if initial.Code != http.StatusOK {
|
|
t.Fatal(initial.Body.String())
|
|
}
|
|
before := s.basicAuth.snapshot()
|
|
blocked := &blockedBasicAuthStore{basicAuthSettingsStore: store, started: make(chan basicAuthConfig, 2), release: make(chan struct{})}
|
|
s.basicAuth.mu.Lock()
|
|
s.basicAuth.store = blocked
|
|
s.basicAuth.mu.Unlock()
|
|
var releaseOnce sync.Once
|
|
release := func() { releaseOnce.Do(func() { close(blocked.release) }) }
|
|
t.Cleanup(release)
|
|
first := make(chan *httptest.ResponseRecorder, 1)
|
|
go func() { first <- saveBasicAuthForTest(s, `{"username":"new-entry"}`) }()
|
|
select {
|
|
case <-blocked.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("save did not reach database")
|
|
}
|
|
// Exercise every kind of request sharing the gate while persistence waits.
|
|
reads := make(chan error, 1)
|
|
go func() {
|
|
if s.basicAuth.snapshot() != before {
|
|
reads <- errors.New("uncommitted settings became visible")
|
|
return
|
|
}
|
|
h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
|
for _, path := range []string{"/", "/_next/static/app.js", "/api/tasks", "/api/health"} {
|
|
r := httptest.NewRequest(http.MethodGet, path, nil)
|
|
if enabled && path != "/api/health" {
|
|
r.AddCookie(initial.Result().Cookies()[0])
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != http.StatusNoContent {
|
|
reads <- errors.New("request rejected while old configuration is still active")
|
|
return
|
|
}
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/health", nil))
|
|
if w.Code != http.StatusOK {
|
|
reads <- errors.New("health handler unavailable during save")
|
|
return
|
|
}
|
|
reads <- nil
|
|
}()
|
|
select {
|
|
case err := <-reads:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("database save blocked configuration reads and HTTP requests")
|
|
}
|
|
// A concurrent partial update must wait and preserve the first update.
|
|
second := make(chan *httptest.ResponseRecorder, 1)
|
|
go func() { second <- saveBasicAuthForTest(s, `{"enabled":true}`) }()
|
|
select {
|
|
case <-blocked.started:
|
|
t.Fatal("concurrent saves reached persistence together")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
release()
|
|
for _, done := range []chan *httptest.ResponseRecorder{first, second} {
|
|
select {
|
|
case response := <-done:
|
|
if response.Code != http.StatusOK {
|
|
t.Fatal(response.Body.String())
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("save did not finish after database release")
|
|
}
|
|
}
|
|
cfg := s.basicAuth.snapshot()
|
|
if cfg.Username != "new-entry" || !cfg.Enabled || cfg.PasswordHash != before.PasswordHash {
|
|
t.Fatal("concurrent partial save lost a committed update")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthBlockedSaveRespectsRequestCancellation(t *testing.T) {
|
|
s, store := basicAuthTestServer(t)
|
|
before := s.basicAuth.snapshot()
|
|
blocked := &blockedBasicAuthStore{basicAuthSettingsStore: store, started: make(chan basicAuthConfig, 1), release: make(chan struct{}), contexts: make(chan context.Context, 1)}
|
|
s.basicAuth.mu.Lock()
|
|
s.basicAuth.store = blocked
|
|
s.basicAuth.mu.Unlock()
|
|
t.Cleanup(func() { close(blocked.release) })
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
r := httptest.NewRequest(http.MethodPut, "/api/settings/basic-auth", strings.NewReader(`{"username":"new-entry"}`)).WithContext(ctx)
|
|
done := make(chan *httptest.ResponseRecorder, 1)
|
|
go func() {
|
|
w := httptest.NewRecorder()
|
|
s.saveBasicAuthSettings(w, r)
|
|
done <- w
|
|
}()
|
|
select {
|
|
case <-blocked.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("save did not reach database")
|
|
}
|
|
writeContext := <-blocked.contexts
|
|
deadline, bounded := writeContext.Deadline()
|
|
if !bounded || time.Until(deadline) > 10*time.Second {
|
|
t.Fatal("database write has no bounded deadline")
|
|
}
|
|
cancel()
|
|
select {
|
|
case w := <-done:
|
|
if w.Code != http.StatusServiceUnavailable || len(w.Result().Cookies()) != 0 {
|
|
t.Fatalf("canceled save must fail without issuing a cookie: %d %s", w.Code, w.Body.String())
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("database save ignored request cancellation")
|
|
}
|
|
if s.basicAuth.snapshot() != before || store.raw != "" {
|
|
t.Fatal("canceled save changed the committed settings")
|
|
}
|
|
// A canceled save must also release the save mutex for a later update.
|
|
s.basicAuth.mu.Lock()
|
|
s.basicAuth.store = store
|
|
s.basicAuth.mu.Unlock()
|
|
w := saveBasicAuthForTest(s, `{"username":"later-entry"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("later save failed: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthGate(t *testing.T) {
|
|
s, _ := basicAuthTestServer(t)
|
|
h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest("GET", "/login", nil))
|
|
if w.Code != 204 {
|
|
t.Fatal("must default to disabled")
|
|
}
|
|
w = saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"密码:test-pass"}`)
|
|
if w.Code != 200 {
|
|
t.Fatalf("enable: %s", w.Body.String())
|
|
}
|
|
cookie := w.Result().Cookies()[0]
|
|
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" {
|
|
t.Fatalf("unsafe cookie: %+v", cookie)
|
|
}
|
|
if verifyJWT(cookie.Value, s.jwtKey) {
|
|
t.Fatal("gate cookie accepted as admin JWT")
|
|
}
|
|
adminJWT, _ := signJWT(s.jwtKey)
|
|
for _, tc := range []struct {
|
|
name, path, user, pass, bearer string
|
|
cookie *http.Cookie
|
|
want int
|
|
}{
|
|
{name: "page challenge", path: "/", want: 401},
|
|
{name: "login challenge", path: "/login", want: 401},
|
|
{name: "asset challenge", path: "/_next/static/app.js", want: 401},
|
|
{name: "auth API challenge", path: "/api/auth/status", want: 401},
|
|
{name: "check challenge", path: "/api/basic-auth/check", want: 401},
|
|
{name: "health exempt", path: "/api/health", want: 204},
|
|
{name: "health prefix not exempt", path: "/api/health/anything", want: 401},
|
|
{name: "wrong password", path: "/login", user: "entry", pass: "wrong", want: 401},
|
|
{name: "wrong user", path: "/login", user: "wrong", pass: "密码:test-pass", want: 401},
|
|
{name: "correct Unicode credentials", path: "/login", user: "entry", pass: "密码:test-pass", want: 204},
|
|
{name: "gate cookie and bearer", path: "/api/tasks", cookie: cookie, bearer: adminJWT, want: 204},
|
|
{name: "admin JWT cannot bypass gate", path: "/api/tasks", bearer: adminJWT, want: 401},
|
|
{name: "tampered cookie", path: "/login", cookie: &http.Cookie{Name: basicAuthCookie, Value: cookie.Value + "x"}, want: 401},
|
|
{name: "admin JWT is not gate cookie", path: "/login", cookie: &http.Cookie{Name: basicAuthCookie, Value: adminJWT}, want: 401},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest("GET", tc.path, nil)
|
|
if tc.user != "" {
|
|
r.SetBasicAuth(tc.user, tc.pass)
|
|
}
|
|
if tc.cookie != nil {
|
|
r.AddCookie(tc.cookie)
|
|
}
|
|
if tc.bearer != "" {
|
|
r.Header.Set("Authorization", "Bearer "+tc.bearer)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != tc.want {
|
|
t.Fatalf("status=%d want %d: %s", w.Code, tc.want, w.Body.String())
|
|
}
|
|
if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="ARTEX", charset="UTF-8"` {
|
|
t.Fatal("missing browser challenge")
|
|
}
|
|
})
|
|
}
|
|
// Passing the outer gate must not grant access to the inner admin API.
|
|
r := httptest.NewRequest("GET", "/api/settings/basic-auth", nil)
|
|
r.AddCookie(cookie)
|
|
w = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
if w.Code != 401 {
|
|
t.Fatal("gate cookie bypassed admin authentication")
|
|
}
|
|
r.Header.Set("Authorization", "Bearer "+adminJWT)
|
|
w = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("gate + JWT rejected: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthSettingsPersistenceAndRevocation(t *testing.T) {
|
|
s, store := basicAuthTestServer(t)
|
|
w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`)
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Body.String())
|
|
}
|
|
oldCookie := w.Result().Cookies()[0]
|
|
before := s.basicAuth.snapshot()
|
|
if strings.Contains(w.Body.String(), "password_hash") || strings.Contains(store.raw, "test-pass") {
|
|
t.Fatal("password leaked")
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(before.PasswordHash), []byte("test-pass")) != nil {
|
|
t.Fatal("password not hashed")
|
|
}
|
|
var restarted basicAuthGate
|
|
if err := restarted.load(store); err != nil || restarted.snapshot() != before {
|
|
t.Fatalf("restart lost config: %v", err)
|
|
}
|
|
w = saveBasicAuthForTest(s, `{"enabled":false,"password":""}`)
|
|
if w.Code != 200 || w.Result().Cookies()[0].MaxAge != -1 {
|
|
t.Fatal("disable failed")
|
|
}
|
|
w = saveBasicAuthForTest(s, `{"enabled":true,"password":""}`)
|
|
if w.Code != 200 || s.basicAuth.snapshot().PasswordHash != before.PasswordHash {
|
|
t.Fatal("blank password must preserve hash")
|
|
}
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.AddCookie(oldCookie)
|
|
if s.validBasicAuthCookie(r, s.basicAuth.snapshot()) {
|
|
t.Fatal("old cookie survived disable/re-enable")
|
|
}
|
|
cookie := w.Result().Cookies()[0]
|
|
w = saveBasicAuthForTest(s, `{"username":"new-entry","password":"new-pass-123"}`)
|
|
r = httptest.NewRequest("GET", "/", nil)
|
|
r.AddCookie(cookie)
|
|
if w.Code != 200 || s.validBasicAuthCookie(r, s.basicAuth.snapshot()) {
|
|
t.Fatal("credential change did not revoke cookie")
|
|
}
|
|
before = s.basicAuth.snapshot()
|
|
store.err = errors.New("DB unavailable")
|
|
w = saveBasicAuthForTest(s, `{"enabled":false}`)
|
|
if w.Code != 503 || s.basicAuth.snapshot() != before {
|
|
t.Fatal("failed save changed live config")
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthValidationAndLoadFailures(t *testing.T) {
|
|
s, _ := basicAuthTestServer(t)
|
|
for _, body := range []string{
|
|
`{"enabled":true}`, `{"enabled":true,"username":"entry"}`,
|
|
`{"username":"bad:name"}`, `{"username":"bad\nname"}`, `{"password":"short"}`,
|
|
`{"password":"` + strings.Repeat("a", 73) + `"}`, `not-json`,
|
|
} {
|
|
if w := saveBasicAuthForTest(s, body); w.Code != 400 {
|
|
t.Fatalf("body=%s status=%d", body, w.Code)
|
|
}
|
|
}
|
|
for _, store := range []*basicAuthMemoryStore{
|
|
{err: errors.New("unavailable")}, {raw: `not-json`}, {raw: `{"enabled":true}`},
|
|
{raw: `{"enabled":true,"username":"entry","revision":"v1","password_hash":"invalid"}`},
|
|
} {
|
|
var gate basicAuthGate
|
|
if err := gate.load(store); err == nil {
|
|
t.Fatal("invalid persisted config did not fail closed")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthExpiredCookieAndSecureFlag(t *testing.T) {
|
|
s, _ := basicAuthTestServer(t)
|
|
w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`)
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Body.String())
|
|
}
|
|
cfg := s.basicAuth.snapshot()
|
|
for _, expires := range []*jwt.NumericDate{nil, jwt.NewNumericDate(time.Now().Add(-time.Minute))} {
|
|
token, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
|
Subject: cfg.Revision, Audience: jwt.ClaimStrings{"artex-basic-auth"}, ExpiresAt: expires,
|
|
}).SignedString(s.basicAuthSigningKey())
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.AddCookie(&http.Cookie{Name: basicAuthCookie, Value: token})
|
|
if s.validBasicAuthCookie(r, cfg) {
|
|
t.Fatal("expired or unbounded cookie accepted")
|
|
}
|
|
}
|
|
r := httptest.NewRequest("GET", "https://example.test/login", nil)
|
|
w = httptest.NewRecorder()
|
|
if err := s.setBasicAuthCookie(w, r, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !w.Result().Cookies()[0].Secure {
|
|
t.Fatal("HTTPS cookie is not Secure")
|
|
}
|
|
var public map[string]any
|
|
w = httptest.NewRecorder()
|
|
s.getBasicAuthSettings(w, r)
|
|
if err := json.Unmarshal(w.Body.Bytes(), &public); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(public) != 3 || public["password_set"] != true {
|
|
t.Fatalf("bad public settings: %v", public)
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthCORS(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
origin string
|
|
credentials bool
|
|
}{
|
|
{"http://127.0.0.1:3000", true}, {"https://evil.test", false}, {"http://127.0.0.1.evil.test:3000", false},
|
|
} {
|
|
r := httptest.NewRequest("OPTIONS", "http://127.0.0.1:8787/api/settings/basic-auth", nil)
|
|
r.Header.Set("Origin", tc.origin)
|
|
w := httptest.NewRecorder()
|
|
cors(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("preflight reached handler") })).ServeHTTP(w, r)
|
|
if w.Code != 204 || (w.Header().Get("Access-Control-Allow-Credentials") == "true") != tc.credentials {
|
|
t.Fatalf("unexpected CORS for %s: %v", tc.origin, w.Header())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthPostgresLoginFlow(t *testing.T) {
|
|
m, err := NewManager(t.TempDir(), "")
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable: %v", err)
|
|
}
|
|
defer m.Close()
|
|
for _, key := range []string{basicAuthKey, authPassKey} {
|
|
old, exists, err := m.pg.GetSetting(key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func(key, old string, exists bool) {
|
|
if exists {
|
|
_ = m.pg.SetSetting(key, old)
|
|
} else {
|
|
_, _ = m.pg.Exec("DELETE FROM settings WHERE key=$1", key)
|
|
}
|
|
}(key, old, exists)
|
|
}
|
|
if err := m.pg.SetSetting(basicAuthKey, `{"enabled":false}`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("admin-password"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.pg.SetSetting(authPassKey, string(hash)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := &Server{m: m, jwtKey: []byte("test-only-signing-key-32-characters")}
|
|
if err := s.basicAuth.load(m.pg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adminJWT, err := signJWT(s.jwtKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := `{"enabled":true,"username":"entry","password":"entry-password"}`
|
|
r := httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
if w.Code != 401 || s.basicAuth.snapshot().Enabled {
|
|
t.Fatal("unauthenticated settings write succeeded")
|
|
}
|
|
r = httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body))
|
|
r.Header.Set("Authorization", "Bearer "+adminJWT)
|
|
w = httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("save: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
restarted := &Server{m: m, jwtKey: s.jwtKey}
|
|
if err := restarted.basicAuth.load(m.pg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loginBody := `{"username":"ARTEX","password":"admin-password"}`
|
|
r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
|
|
w = httptest.NewRecorder()
|
|
restarted.Handler().ServeHTTP(w, r)
|
|
if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
|
|
t.Fatal("login was not protected after restart")
|
|
}
|
|
r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
|
|
r.SetBasicAuth("entry", "entry-password")
|
|
w = httptest.NewRecorder()
|
|
restarted.Handler().ServeHTTP(w, r)
|
|
var login struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil || w.Code != 200 || !verifyJWT(login.Token, s.jwtKey) {
|
|
t.Fatalf("Basic Auth + existing login failed: %d %s", w.Code, w.Body.String())
|
|
}
|
|
cookie := w.Result().Cookies()[0]
|
|
r = httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(`{"enabled":false}`))
|
|
r.AddCookie(cookie)
|
|
r.Header.Set("Authorization", "Bearer "+login.Token)
|
|
w = httptest.NewRecorder()
|
|
restarted.Handler().ServeHTTP(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("disable with gate cookie and JWT: %d %s", w.Code, w.Body.String())
|
|
}
|
|
}
|