From c01d700482cf4dbdbcdf3247bf7f3c226dbe3b9a Mon Sep 17 00:00:00 2001 From: Angel Beltran Date: Tue, 30 Dec 2025 14:42:04 -0700 Subject: [PATCH] http response tooling written --- internal/site/accounts.go | 13 ++- internal/site/auth.go | 28 +++---- internal/site/cookie.go | 6 +- internal/site/login.go | 52 ++++++------ internal/site/redirect/code.go | 19 +++++ internal/site/response/cookie.go | 83 +++++++++++++++++++ internal/site/response/error.go | 124 +++++++++++++++++++++++++++++ internal/site/response/handler.go | 18 +++++ internal/site/response/json.go | 83 +++++++++++++++++++ internal/site/response/redirect.go | 120 ++++++++++++++++++++++++++++ internal/site/response/response.go | 24 ++++++ internal/site/response/status.go | 81 +++++++++++++++++++ internal/site/response/write.go | 56 +++++++++++++ internal/site/site.go | 13 +-- 14 files changed, 660 insertions(+), 60 deletions(-) create mode 100644 internal/site/redirect/code.go create mode 100644 internal/site/response/cookie.go create mode 100644 internal/site/response/error.go create mode 100644 internal/site/response/handler.go create mode 100644 internal/site/response/json.go create mode 100644 internal/site/response/redirect.go create mode 100644 internal/site/response/response.go create mode 100644 internal/site/response/status.go create mode 100644 internal/site/response/write.go diff --git a/internal/site/accounts.go b/internal/site/accounts.go index ac6385a..0edd443 100644 --- a/internal/site/accounts.go +++ b/internal/site/accounts.go @@ -3,25 +3,24 @@ package site import ( "fmt" "net/http" + + "ruben/inventory2/internal/site/response" ) // POST /accounts -func (s *Server) createAccount(w http.ResponseWriter, r *http.Request) { +func (s *Server) createAccount(r *http.Request) (response.Response, error) { ctx := r.Context() email := r.FormValue("email") if email == "" { - http.Error(w, "no email provided", http.StatusBadRequest) - return + return nil, response.BadRequest().Msg("no email provided") } userID := getAccessTokenClaims(ctx).Subject acct, err := s.accts.CreateAccount(ctx, userID, email) if err != nil { - http.Error(w, fmt.Sprintf("failed to create account: %v", err), http.StatusInternalServerError) - return + return nil, response.Errorf("failed to create account: %w", err) } - //http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acct.ID), http.StatusSeeOther) - http.Redirect(w, r, fmt.Sprintf("/accounts/%d", acct.ID), http.StatusSeeOther) + return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.ID)), nil } diff --git a/internal/site/auth.go b/internal/site/auth.go index 4a3fb31..7d2c27c 100644 --- a/internal/site/auth.go +++ b/internal/site/auth.go @@ -9,24 +9,24 @@ import ( "ruben/inventory2/internal/consts" "ruben/inventory2/internal/domains/authentication" + "ruben/inventory2/internal/site/response" ) // just keep this around long enough for testing auth middleware.. -func (s *Server) testAuthEndpoint(w http.ResponseWriter, r *http.Request) { - fmt.Println("SUCCESS:", getAccessTokenClaims(r.Context())) +func (s *Server) testAuthEndpoint(r *http.Request) (response.Response, error) { + fmt.Println("AUTH TEST SUCCESS:", getAccessTokenClaims(r.Context())) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return response.TemporaryRedirect("/"), nil } type customClaimsKey struct{} // auth middleware to verify access_token cookie and set custom claims in the request context -func (s *Server) authenticate(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func (s *Server) authenticate(f response.HandlerFunc) response.HandlerFunc { + return func(r *http.Request) (response.Response, error) { ck, err := r.Cookie("access_token") if err != nil { - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return + return response.TemporaryRedirect("/"), nil } ctx := r.Context() @@ -34,22 +34,18 @@ func (s *Server) authenticate(h http.Handler) http.Handler { claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, ck.Value) if err != nil { if errors.Is(err, consts.ErrNotFound) { - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return + return response.TemporaryRedirect("/"), nil } - http.Error(w, fmt.Sprintf("failed to authenticate: %v", err), http.StatusInternalServerError) - return + return nil, response.Errorf("failed to authenticate: %w", err) } if expiration.Before(time.Now()) { - deleteCookieInResponse(w, "access_token") - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return + return response.TemporaryRedirect("/").Cookie(getExpiredCookie("access_token")), nil } - h.ServeHTTP(w, r.WithContext(setAccessTokenClaims(ctx, claims))) - }) + return f(r.WithContext(setAccessTokenClaims(ctx, claims))) + } } func (s *Server) getAccessTokenClaims(r *http.Request) (authentication.AccessTokenClaims, bool) { diff --git a/internal/site/cookie.go b/internal/site/cookie.go index 7214885..41cb0c1 100644 --- a/internal/site/cookie.go +++ b/internal/site/cookie.go @@ -2,10 +2,10 @@ package site import "net/http" -func deleteCookieInResponse(w http.ResponseWriter, name string) { - w.Header().Set("Set-Cookie", (&http.Cookie{ +func getExpiredCookie(name string) http.Cookie { + return http.Cookie{ Name: name, Path: "/", MaxAge: -1, // expire the cookie - }).String()) + } } diff --git a/internal/site/login.go b/internal/site/login.go index daa1e52..a29910d 100644 --- a/internal/site/login.go +++ b/internal/site/login.go @@ -3,44 +3,41 @@ package site import ( "fmt" "net/http" + "ruben/inventory2/internal/site/response" ) // GET /login -func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { +func (s *Server) loginPage(r *http.Request) (response.Response, error) { ctx := r.Context() state, err := s.auth.NewState(ctx) if err != nil { - http.Error(w, fmt.Sprintf("failed to generate random state: %v", err), http.StatusInternalServerError) - return + return nil, response.Errorf("failed to generate random state: %w", err) } base64EncodedState := fmt.Sprintf("%x", state[:]) - http.Redirect(w, r, s.auth.AuthCodeURL(base64EncodedState), http.StatusTemporaryRedirect) + return response.TemporaryRedirect(s.auth.AuthCodeURL(base64EncodedState)), nil } // POST /login -func (s *Server) login(w http.ResponseWriter, r *http.Request) { +func (s *Server) login(r *http.Request) (response.Response, error) { ctx := r.Context() email := r.FormValue("email") if email == "" { - http.Error(w, "no email provided", http.StatusBadRequest) - return + return nil, response.BadRequest().Msg("no email provided") } acct, err := s.accts.GetAccountByEmail(ctx, email) if err != nil { - http.Error(w, fmt.Sprintf("failed to create account: %v", err), http.StatusInternalServerError) - return + return nil, response.Errorf("failed to create account: %w", err) } - //http.Redirect(w, r, fmt.Sprintf("/site/accounts/%d", acct.ID), http.StatusSeeOther) - http.Redirect(w, r, fmt.Sprintf("/accounts/%d", acct.ID), http.StatusSeeOther) + return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.ID)), nil } // GET /login/callback -func (s *Server) loginCallback(w http.ResponseWriter, r *http.Request) { +func (s *Server) loginCallback(r *http.Request) (response.Response, error) { ctx := r.Context() q := r.URL.Query() @@ -48,38 +45,37 @@ func (s *Server) loginCallback(w http.ResponseWriter, r *http.Request) { accessToken, expiration, err := s.auth.Exchange(ctx, q.Get("state"), q.Get("code")) if err != nil { - http.Error(w, fmt.Sprintf("Failed to exchange an authorization code for a token: %v", err), http.StatusUnauthorized) - return + return nil, response.Unauthorized(). + Msg(fmt.Sprintf("Failed to exchange an authorization code for a token")). + Wrap(err) } // set access_token cookie and redirect to a reasonable place - w.Header().Set("Set-Cookie", (&http.Cookie{ - Name: "access_token", - Value: accessToken, - Path: "/", - Expires: expiration, - MaxAge: 0, // using Expiration instead - Secure: true, - }).String()) - - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return response.TemporaryRedirect("/"). + Cookie(http.Cookie{ + Name: "access_token", + Value: accessToken, + Path: "/", + Expires: expiration, + MaxAge: 0, // using Expiration instead + Secure: true, + }), nil } // GET /logout -func (s *Server) logoutPage(w http.ResponseWriter, r *http.Request) { +func (s *Server) logoutPage(r *http.Request) (response.Response, error) { host := r.Header.Get("X-Forwarded-Host") if host == "" { host = r.Host } - deleteCookieInResponse(w, "access_token") - if ck, err := r.Cookie("access_token"); err == nil && ck != nil { if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil { fmt.Println("[ERROR] failed to delete auth token:", err) } } - http.Redirect(w, r, s.auth.GetLogoutURL(host).String(), http.StatusTemporaryRedirect) + return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()). + Cookie(getExpiredCookie("access_token")), nil } diff --git a/internal/site/redirect/code.go b/internal/site/redirect/code.go new file mode 100644 index 0000000..1597a63 --- /dev/null +++ b/internal/site/redirect/code.go @@ -0,0 +1,19 @@ +package redirect + +import "net/http" + +type ( + Code int +) + +var ( + MovedPermanently = Code(http.StatusMovedPermanently) + Found = Code(http.StatusFound) + SeeOther = Code(http.StatusSeeOther) + TemporaryRedirect = Code(http.StatusTemporaryRedirect) + PermanentRedirect = Code(http.StatusPermanentRedirect) +) + +func (c Code) Int() int { + return int(c) +} diff --git a/internal/site/response/cookie.go b/internal/site/response/cookie.go new file mode 100644 index 0000000..7b6316e --- /dev/null +++ b/internal/site/response/cookie.go @@ -0,0 +1,83 @@ +package response + +import ( + "fmt" + "io" + "net/http" + "ruben/inventory2/internal/site/redirect" +) + +type ( + cookieRes struct { + cookie http.Cookie + res Response + } +) + +var _ Response = cookieRes{} + +func Cookie(c http.Cookie) Response { + return cookieRes{ + cookie: c, + } +} + +func (c cookieRes) String() string { + if c.res != nil { + return fmt.Sprintf(`{"cookie": %q, "nested": %s}`, c.cookie, c.res) + } + return fmt.Sprintf(`{"cookie": %q}`, c.cookie) +} + +func (c cookieRes) wrap(res Response) Response { + c.res = res + return c +} + +func (c cookieRes) Status(code int) Response { + return Status(code).wrap(c) +} + +func (c cookieRes) Redirect(code redirect.Code, to string) Response { + return Redirect(code, to).wrap(c) +} + +func (c cookieRes) JSON(body any) Response { + return JSON(body).wrap(c) +} + +func (c cookieRes) Cookie(ck http.Cookie) Response { + return Cookie(ck).wrap(c) +} + +func (c cookieRes) getStatus() (int, bool) { + if c.res == nil { + return 0, false + } + + return c.res.getStatus() +} + +func (c cookieRes) getRedirect() (code redirect.Code, to string, ok bool) { + if c.res == nil { + return 0, "", false + } + + return c.res.getRedirect() +} + +func (c cookieRes) getBody() (body io.ReadCloser, ok bool, err error) { + if c.res == nil { + return nil, false, nil + } + + return c.res.getBody() +} + +func (c cookieRes) getCookies() []http.Cookie { + if c.res != nil { + return append(c.res.getCookies(), c.cookie) + } + + return []http.Cookie{c.cookie} +} diff --git a/internal/site/response/error.go b/internal/site/response/error.go new file mode 100644 index 0000000..d412522 --- /dev/null +++ b/internal/site/response/error.go @@ -0,0 +1,124 @@ +package response + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +type ( + // ErrorResponse is an error + ErrorResponse struct { + err error + msg string + status int + } +) + +// Constructors + +func Errorf(format string, args ...any) ErrorResponse { + return ErrorResponse{ + err: fmt.Errorf(format, args...), + } +} + +func BadRequest() ErrorResponse { + return ErrorResponse{ + status: http.StatusBadRequest, + } +} + +func NotFound() ErrorResponse { + return ErrorResponse{ + status: http.StatusNotFound, + } +} + +func Unauthorized() ErrorResponse { + return ErrorResponse{ + status: http.StatusUnauthorized, + } +} + +func Forbidden() ErrorResponse { + return ErrorResponse{ + status: http.StatusForbidden, + } +} + +// builder pattern implementation + +func (e ErrorResponse) Msg(msg string) ErrorResponse { + e.msg = msg + return e +} + +func (e ErrorResponse) Status(status int) ErrorResponse { + e.status = status + return e +} + +func (e ErrorResponse) Wrap(err error) ErrorResponse { + e.err = err + return e +} + +// error implementation + +func (e ErrorResponse) Error() string { + parts := make([]string, 0, 3) + + if e.msg != "" { + parts = append(parts, e.msg) + } else if e.status != 0 { + parts = append(parts, fmt.Sprintf("status = %d", e.status)) + } + if e.err != nil { + parts = append(parts, e.err.Error()) + } + + if len(parts) == 0 { + return "status = 500" + } + + return strings.Join(parts, ": ") +} + +func (e ErrorResponse) Unwrap() error { + return e.err +} + +// nested response value resolution + +func (e ErrorResponse) getStatus() (int, bool) { + if e.status != 0 { + return e.status, true + } + + ce, ok := getError(e.err) + if ok { + return ce.getStatus() + } + + return 0, false +} + +func (e ErrorResponse) getMsg() (string, bool) { + if e.msg != "" { + return e.msg, true + } + + ce, ok := getError(e.err) + if ok { + return ce.getMsg() + } + + return "", false +} + +func getError(err error) (e ErrorResponse, ok bool) { + ok = errors.As(err, &e) + return e, ok +} diff --git a/internal/site/response/handler.go b/internal/site/response/handler.go new file mode 100644 index 0000000..4d7674c --- /dev/null +++ b/internal/site/response/handler.go @@ -0,0 +1,18 @@ +package response + +import ( + "net/http" +) + +type HandlerFunc = func(r *http.Request) (Response, error) + +func Handler(f HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + res, err := f(r) + if err != nil { + WriteError(w, err) + } else { + Write(w, r, res) + } + } +} diff --git a/internal/site/response/json.go b/internal/site/response/json.go new file mode 100644 index 0000000..8116d95 --- /dev/null +++ b/internal/site/response/json.go @@ -0,0 +1,83 @@ +package response + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "ruben/inventory2/internal/site/redirect" +) + +type ( + jsonRes struct { + body any + res Response + } +) + +var _ Response = jsonRes{} + +func JSON(body any) Response { + return jsonRes{ + body: body, + } +} + +func (j jsonRes) String() string { + if j.res != nil { + return fmt.Sprintf(`{"body": %q, "nested": %s}`, j.body, j.res) + } + return fmt.Sprintf(`{"body": %q}`, j.body) +} + +func (j jsonRes) wrap(res Response) Response { + j.res = res + return j +} + +func (j jsonRes) Status(code int) Response { + return Status(code).wrap(j) +} + +func (j jsonRes) Redirect(code redirect.Code, to string) Response { + return Redirect(code, to).wrap(j) +} + +func (j jsonRes) JSON(body any) Response { + j.body = body + return j +} + +func (j jsonRes) Cookie(ck http.Cookie) Response { + return Cookie(ck).wrap(j) +} + +func (j jsonRes) getStatus() (int, bool) { + if j.res == nil { + return 0, false + } + + return j.res.getStatus() +} + +func (j jsonRes) getRedirect() (code redirect.Code, to string, ok bool) { + if j.res == nil { + return 0, "", false + } + + return j.res.getRedirect() +} + +func (j jsonRes) getBody() (body io.ReadCloser, ok bool, err error) { + buf := new(bytes.Buffer) + return io.NopCloser(buf), true, json.NewEncoder(buf).Encode(j.body) +} + +func (j jsonRes) getCookies() []http.Cookie { + if j.res != nil { + return j.res.getCookies() + } + + return nil +} diff --git a/internal/site/response/redirect.go b/internal/site/response/redirect.go new file mode 100644 index 0000000..0dab1a1 --- /dev/null +++ b/internal/site/response/redirect.go @@ -0,0 +1,120 @@ +package response + +import ( + "fmt" + "io" + "net/http" + "ruben/inventory2/internal/site/redirect" +) + +type ( + redirectRes struct { + code redirect.Code + to string + res Response + } +) + +var _ Response = redirectRes{} + +// convenience constructors + +func MovedPermanently(to string) Response { + return redirectRes{ + code: redirect.MovedPermanently, + to: to, + } +} + +func Found(to string) Response { + return redirectRes{ + code: redirect.Found, + to: to, + } +} + +func SeeOther(to string) Response { + return redirectRes{ + code: redirect.SeeOther, + to: to, + } +} + +func TemporaryRedirect(to string) Response { + return redirectRes{ + code: redirect.TemporaryRedirect, + to: to, + } +} + +func PermanentRedirect(to string) Response { + return redirectRes{ + code: redirect.PermanentRedirect, + to: to, + } +} + +func Redirect(code redirect.Code, to string) Response { + return redirectRes{ + code: code, + to: to, + } +} + +func (r redirectRes) String() string { + if r.res != nil { + return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}, "nested": %s}`, r.code, r.to, r.res) + } + return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}}`, r.code, r.to) +} + +func (r redirectRes) wrap(res Response) Response { + r.res = res + return r +} + +func (r redirectRes) Status(code int) Response { + return Status(code).wrap(r) +} + +func (r redirectRes) Redirect(code redirect.Code, to string) Response { + r.code = code + r.to = to + return r +} + +func (r redirectRes) JSON(body any) Response { + return JSON(body).wrap(r) +} + +func (r redirectRes) Cookie(ck http.Cookie) Response { + return Cookie(ck).wrap(r) +} + +func (r redirectRes) getStatus() (int, bool) { + if r.res == nil { + return 0, false + } + + return r.res.getStatus() +} + +func (r redirectRes) getRedirect() (code redirect.Code, to string, ok bool) { + return r.code, r.to, true +} + +func (r redirectRes) getBody() (body io.ReadCloser, ok bool, err error) { + if r.res == nil { + return nil, false, nil + } + + return r.res.getBody() +} + +func (r redirectRes) getCookies() []http.Cookie { + if r.res != nil { + return r.res.getCookies() + } + + return nil +} diff --git a/internal/site/response/response.go b/internal/site/response/response.go new file mode 100644 index 0000000..ee03842 --- /dev/null +++ b/internal/site/response/response.go @@ -0,0 +1,24 @@ +package response + +import ( + "io" + "net/http" + + "ruben/inventory2/internal/site/redirect" +) + +type ( + Response interface { + Status(int) Response + Redirect(code redirect.Code, to string) Response + JSON(any) Response + Cookie(http.Cookie) Response + + getStatus() (code int, ok bool) + getBody() (body io.ReadCloser, ok bool, err error) + getRedirect() (code redirect.Code, to string, ok bool) + getCookies() []http.Cookie + + wrap(Response) Response + } +) diff --git a/internal/site/response/status.go b/internal/site/response/status.go new file mode 100644 index 0000000..6b12e74 --- /dev/null +++ b/internal/site/response/status.go @@ -0,0 +1,81 @@ +package response + +import ( + "fmt" + "io" + "net/http" + + "ruben/inventory2/internal/site/redirect" +) + +type ( + statusRes struct { + code int + res Response + } +) + +var _ Response = statusRes{} + +func Status(code int) Response { + return statusRes{ + code: code, + } +} + +func (s statusRes) String() string { + if s.res != nil { + return fmt.Sprintf(`{"status": %d, "nested": %s}`, s.code, s.res) + } + return fmt.Sprintf(`{"status": %d}`, s.code) +} + +func (s statusRes) wrap(res Response) Response { + s.res = res + return s +} + +func (s statusRes) Status(code int) Response { + s.code = code + return s +} + +func (s statusRes) Redirect(code redirect.Code, to string) Response { + return Redirect(code, to).wrap(s) +} + +func (s statusRes) JSON(body any) Response { + return JSON(body).wrap(s) +} + +func (s statusRes) Cookie(ck http.Cookie) Response { + return Cookie(ck).wrap(s) +} + +func (s statusRes) getStatus() (int, bool) { + return s.code, true +} + +func (s statusRes) getRedirect() (code redirect.Code, to string, ok bool) { + if s.res == nil { + return 0, "", false + } + + return s.res.getRedirect() +} + +func (s statusRes) getBody() (body io.ReadCloser, ok bool, err error) { + if s.res == nil { + return nil, false, nil + } + + return s.res.getBody() +} + +func (s statusRes) getCookies() []http.Cookie { + if s.res != nil { + return s.res.getCookies() + } + + return nil +} diff --git a/internal/site/response/write.go b/internal/site/response/write.go new file mode 100644 index 0000000..fc51fa2 --- /dev/null +++ b/internal/site/response/write.go @@ -0,0 +1,56 @@ +package response + +import ( + "fmt" + "io" + "net/http" +) + +func Write(w http.ResponseWriter, r *http.Request, res Response) { + // w.Header() must be set before ResponseWriter.WriteHeader is called + // or redirect is attempted + hdrs := w.Header() + for _, ck := range res.getCookies() { + hdrs.Add("Set-Cookie", ck.String()) + } + + if code, to, ok := res.getRedirect(); ok { + http.Redirect(w, r, to, code.Int()) + return + } + + // the body is written after the status header, but it's read here + // first, because if an error is incurred, an error status header will + // need to be written. + body, bodySet, err := res.getBody() + if err != nil { + http.Error( + w, + fmt.Sprintf("Failed to construct response body: %v", err.Error()), + http.StatusInternalServerError, + ) + return + } + + if status, ok := res.getStatus(); ok { + w.WriteHeader(status) + } + if bodySet { + // will automatically set the status header, + // if w.WriteHeader wasn't already called + io.Copy(w, body) + } + +} + +func WriteError(w http.ResponseWriter, err error) { + var status int + + if e, ok := getError(err); ok { + if status, ok = e.getStatus(); !ok { + status = http.StatusInternalServerError + } + } + + http.Error(w, err.Error(), status) +} diff --git a/internal/site/site.go b/internal/site/site.go index fd35052..01a7554 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -15,6 +15,7 @@ import ( "ruben/inventory2/internal/domains/authentication" etsy_platform "ruben/inventory2/internal/domains/platforms/etsy" "ruben/inventory2/internal/domains/raw_events" + "ruben/inventory2/internal/site/response" ) type Server struct { @@ -90,16 +91,16 @@ func NewServer( // api routes - s.mux.HandleFunc("GET /login", s.loginPage) - s.mux.HandleFunc("GET /login/callback", s.loginCallback) - s.mux.HandleFunc("GET /logout", s.logoutPage) - s.mux.Handle("POST /accounts", s.authenticate(http.HandlerFunc(s.createAccount))) + s.mux.HandleFunc("GET /login", response.Handler(s.loginPage)) + s.mux.HandleFunc("GET /login/callback", response.Handler(s.loginCallback)) + s.mux.HandleFunc("GET /logout", response.Handler(s.logoutPage)) + s.mux.Handle("POST /accounts", response.Handler(s.authenticate(s.createAccount))) // TODO: eliminate once no longer used. - s.mux.HandleFunc("POST /login", s.login) + s.mux.HandleFunc("POST /login", response.Handler(s.login)) // TODO: get rid of this, once we're confident this isn't needed... - s.mux.Handle("GET /test-auth", s.authenticate(http.HandlerFunc(s.testAuthEndpoint))) + s.mux.Handle("GET /test-auth", response.Handler(s.authenticate(s.testAuthEndpoint))) // webpage content