subrouters

This commit is contained in:
2026-01-12 04:54:53 -07:00
parent a72fd9e0e8
commit 0d6af06d65
27 changed files with 950 additions and 174 deletions
+195
View File
@@ -0,0 +1,195 @@
package accounts
import (
"errors"
"fmt"
"log/slog"
"net/http"
"ruben/inventory2/internal/consts"
"ruben/inventory2/internal/domains/accounts"
"ruben/inventory2/internal/server/middleware"
"ruben/inventory2/internal/server/response"
"ruben/inventory2/internal/server/router"
"strconv"
)
type accountSubrouter struct {
log *slog.Logger
*router.SubMux
accts *accounts.Store
}
func NewAccountSubrouter(
logger *slog.Logger,
accts *accounts.Store,
authMiddleware *middleware.Auth,
) *accountSubrouter {
mux := router.NewSubMux(logger)
as := &accountSubrouter{
log: logger,
SubMux: mux,
accts: accts,
}
withAuth := func(fn response.HandlerFunc) response.HandlerFunc {
return authMiddleware.AuthenticateAndAddIdentity(fn)
}
mux.Handle("POST /{acctID}/inventory/sync-groups/draft/listings", withAuth(as.createSyncGroupListingDraft))
mux.Handle("PUT /{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop", withAuth(as.setShopInSyncGroupListingDraft))
mux.Handle("PUT /{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing", withAuth(as.setListingInSyncGroupListingDraft))
mux.Handle("DELETE /{acctID}/inventory/sync-groups/draft/listings/{orderIndex}", withAuth(as.deleteSyncGroupListingDraft))
mux.Handle("POST /{acctID}/inventory/sync-groups", withAuth(as.saveNewSyncGroup))
return as
}
// POST /accounts
func (s *accountSubrouter) createAccount(r *http.Request) (response.Response, error) {
ctx := r.Context()
email := r.FormValue("email")
if email == "" {
return nil, response.BadRequest().Msg("no email provided")
}
userID := middleware.GetIdentity(ctx).User.UserID
acct, err := s.accts.CreateAccount(ctx, userID, email)
if err != nil {
if errors.Is(err, consts.ErrConflict) {
return nil, response.Conflict().
Msg("user already has an account")
}
return nil, response.Errorf("failed to create account: %w", err)
}
return response.SeeOther(fmt.Sprintf("/accounts/%d", acct.AccountID)), nil
}
// POST /accounts/{acctID}/inventory/sync-groups/draft/listings
func (s *accountSubrouter) createSyncGroupListingDraft(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
orderIndex, err := s.accts.CreateSyncGroupListingDraft(ctx, acctID)
if err != nil {
return nil, response.Errorf("failed to create new listing draft: %w", err)
}
return response.Redirect(
http.StatusSeeOther,
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
), nil
}
// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop
// @platform string
// @shopID string
func (s *accountSubrouter) setShopInSyncGroupListingDraft(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
if err != nil {
return nil, err
}
platformStr := r.FormValue("platform")
platform, err := accounts.NewPlatform(platformStr)
if err != nil {
return nil, response.BadRequest().
Msgf("unrecognized platform: %s", platformStr)
}
shopID := r.FormValue("shop-id")
if shopID == "" {
return nil, response.BadRequest().
Msg("no shop-id provided")
}
if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil {
return nil, response.Errorf("failed to set shop: %w", response.ErrorFromConstant(err))
}
return response.Redirect(
http.StatusSeeOther,
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
), nil
}
// PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing
func (s *accountSubrouter) setListingInSyncGroupListingDraft(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
if err != nil {
return nil, err
}
listingID := r.FormValue("listing-id")
if listingID == "" {
return nil, response.BadRequest().
Msg("no listing-id provided")
}
if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil {
return nil, response.Errorf("failed to set listing: %w", response.ErrorFromConstant(err))
}
return response.Redirect(
http.StatusSeeOther,
fmt.Sprintf("/accounts/%d/inventory/sync-groups/draft/listings/%d", acctID, orderIndex),
), nil
}
// DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}
func (s *accountSubrouter) deleteSyncGroupListingDraft(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
orderIndex, err := getOrderIndexForSyncGroupListingDraftFromPath(r)
if err != nil {
return nil, err
}
if _, err := s.accts.DeleteSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil {
return nil, response.Errorf("failed to delete listing: %w", response.ErrorFromConstant(err))
}
return response.Status(200), nil
}
// POST /accounts/{acctID}/inventory/sync-groups
func (s *accountSubrouter) saveNewSyncGroup(r *http.Request) (response.Response, error) {
ctx := r.Context()
acctID := middleware.GetIdentity(ctx).Account.AccountID
grp, err := s.accts.SaveNewSyncGroup(ctx, acctID)
if err != nil {
return nil, response.Errorf("failed to save new sync group: %w", response.ErrorFromConstant(err))
}
return response.Redirect(
http.StatusSeeOther,
// TODO: template not implemented
fmt.Sprintf("/accounts/%d/inventory/sync-groups/%d", acctID, grp.SyncGroupID),
), nil
}
func getOrderIndexForSyncGroupListingDraftFromPath(r *http.Request) (int, error) {
orderIndexStr := r.PathValue("orderIndex")
if orderIndexStr == "" {
return 0, response.NotFound().
Msg("no orderIndex found")
}
orderIndex, err := strconv.Atoi(orderIndexStr)
if err != nil {
return 0, response.NotFound().
Msgf("invalid order index: %s", orderIndexStr)
}
return orderIndex, nil
}
+107
View File
@@ -0,0 +1,107 @@
package auth
import (
"context"
"fmt"
"log/slog"
"net/http"
"ruben/inventory2/internal/domains/authentication"
"ruben/inventory2/internal/server/cookies"
"ruben/inventory2/internal/server/response"
"ruben/inventory2/internal/server/router"
)
type loginSubrouter struct {
log *slog.Logger
auth *authentication.Authenticator
router.Subrouter
}
func NewLoginSubrouter(logger *slog.Logger, auth *authentication.Authenticator) *loginSubrouter {
mux := router.NewSubMux(logger)
ls := &loginSubrouter{
log: logger,
auth: auth,
Subrouter: mux,
}
mux.Handle("GET /login", ls.loginPage)
mux.Handle("GET /login/callback", ls.loginCallback)
mux.Handle("GET /logout", ls.logoutPage)
return ls
}
func (s *loginSubrouter) newLoginSubrouter(logger *slog.Logger) router.Subrouter {
mux := router.NewSubMux(logger)
ls := &loginSubrouter{
log: s.log,
auth: s.auth,
Subrouter: mux,
}
mux.Handle("GET /login", ls.loginPage)
mux.Handle("GET /login/callback", ls.loginCallback)
mux.Handle("GET /logout", ls.logoutPage)
return ls
}
func (s *loginSubrouter) loginPage(r *http.Request) (response.Response, error) {
ctx := r.Context()
u, err := NewLoginURL(ctx, s.auth, "/")
if err != nil {
return nil, err
}
return response.TemporaryRedirect(u), nil
}
func NewLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) {
state, err := auth.NewState(ctx, targetURI)
if err != nil {
return "", fmt.Errorf("failed to generate random state: %w", err)
}
base64EncodedState := fmt.Sprintf("%x", state[:])
return auth.AuthCodeURL(base64EncodedState), nil
}
func (s *loginSubrouter) loginCallback(r *http.Request) (response.Response, error) {
ctx := r.Context()
q := r.URL.Query()
// obtain token and profile
accessToken, targetURI, expiration, err := s.auth.Exchange(ctx, q.Get("state"), q.Get("code"))
if err != nil {
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
return response.TemporaryRedirect(targetURI).
Cookie(cookies.AccessToken(accessToken, expiration)), nil
}
func (s *loginSubrouter) logoutPage(r *http.Request) (response.Response, error) {
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
if ck, err := r.Cookie("access_token"); err == nil && ck != nil {
if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil {
s.log.Error("failed to delete auth token", "error", err)
}
}
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
Cookie(cookies.Expired("access_token")), nil
}
+285
View File
@@ -0,0 +1,285 @@
package templates
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"ruben/inventory2/internal/domains/accounts"
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/server/middleware"
"ruben/inventory2/internal/server/response"
"ruben/inventory2/internal/server/router"
"github.com/angelbeltran/templater"
)
type webpageRouter struct {
log *slog.Logger
contentDir string
templater *templater.Templater
rawEvents *raw_events.Store
accts *accounts.Store
etsy *etsy_platform.Platform
router.Subrouter
}
func NewWebpageRouter(
logger *slog.Logger,
contentDir string,
tmpl *templater.Templater,
rawEvents *raw_events.Store,
accts *accounts.Store,
etsy *etsy_platform.Platform,
authMiddleware *middleware.Auth,
) *webpageRouter {
mux := router.NewSubMux(logger)
wr := &webpageRouter{
log: logger,
contentDir: contentDir,
templater: tmpl,
rawEvents: rawEvents,
accts: accts,
etsy: etsy,
Subrouter: mux,
}
// non-authenticated
mux.Handle("GET /{$}", authMiddleware.AddIdentity(wr.serveTemplates))
// authenticated
mux.Handle("GET /", authMiddleware.AuthenticateAndAddIdentity(wr.serveTemplates))
return wr
}
// GET /
// compiles the page template or component template matching the url
// func (s *Server) serveTemplates(r *http.Request) (response.Response, error) {
func (s *webpageRouter) serveTemplates(r *http.Request) (response.Response, error) {
fmt.Println("webpageRouter.serveTemplates:", r.URL)
name, args := s.getTemplateNameAndArgs(r, s.contentDir+"/templates/component_bodies")
b, err := s.templater.ExecuteComponentBody(name, args...)
if err == nil {
return response.HTML(b), nil
}
if !isFileNotFoundError(err) {
return s.handleTemplateError(err, args...)
}
name, args = s.getTemplateNameAndArgs(r, s.contentDir+"/templates/page_bodies")
if b, err = s.templater.ExecutePage(name, args...); err == nil {
return response.HTML(b), nil
}
return s.handleTemplateError(err, args...)
}
// func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (name string, args []any) {
func (s *webpageRouter) getTemplateNameAndArgs(r *http.Request, templateDir string) (name string, args []any) {
fmt.Println("webpageRouter.getTemplateNameAndArgs:", r.URL)
ctx := r.Context()
name, pathParams := getTemplateNameForURL(r.URL, templateDir)
return name, []any{
"Request",
r,
// add services and data here
"RawEvents",
s.rawEvents.WithContext(ctx),
"URLCalc",
newURLCalculator(r.URL),
"PathParams",
pathParams,
"Accounts",
s.accts.WithContext(ctx),
"Etsy",
s.etsy.WithContext(ctx),
// TODO: apply auth to all templates needed!
// auth tooling
/*
AccessToken string
Claims authentication.AccessTokenClaims
User accounts.OAuthUser
Account *accounts.Account
*/
"Identity",
middleware.GetIdentity(r.Context()),
"Auth",
newTemplateAuthenticator(r),
}
}
// TODO: clean this up...
// TODO: somehow tell what the path params are and pass them up.
// - then consider pushing this functionality into the template library.
//
// getComponentTemplateNameForURL eliminate any trailing .html or /, and checks for any
// file with path parameters in the name, eg '{abc}.html.tmpl', prefering exact filename matches.
func getTemplateNameForURL(u *url.URL, templateDir string) (name string, params map[string]string) {
templateDir = path.Clean(templateDir)
fp := strings.TrimPrefix(strings.TrimSuffix(strings.TrimSuffix(u.Path, ".html"), "/"), "/")
if fp == "" {
// "/" maps to "/index"
fp = "index"
}
fpParts := strings.Split(fp, "/")
res := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(fpParts)
for _, combs := range res {
pattern := path.Join(templateDir, path.Join(combs...)) + ".html.tmpl"
matches, _ := filepath.Glob(pattern)
if len(matches) == 0 {
pattern := path.Join(templateDir, path.Join(combs...), "index") + ".html.tmpl"
matches, _ = filepath.Glob(pattern)
}
if len(matches) > 0 {
match := matches[0]
name = strings.TrimPrefix(strings.TrimSuffix(match, ".html.tmpl"), templateDir+"/")
patternParts := strings.Split(name, "/")
params = make(map[string]string)
for i, pp := range patternParts {
if strings.HasPrefix(pp, "{") && strings.HasSuffix(pp, "}") {
params[pp[1:len(pp)-1]] = fpParts[i]
}
}
return name, params
}
}
return fp, nil
}
func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts []string) [][]string {
switch len(filepathParts) {
case 0:
return nil
case 1:
return [][]string{
[]string{filepathParts[0]},
[]string{"{*}"},
}
default:
tailCombs := getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(filepathParts[1:])
combs := make([][]string, 2*len(tailCombs))
for i, c := range tailCombs {
combs[i*2] = append([]string{filepathParts[0]}, c...)
combs[i*2+1] = append([]string{"{*}"}, c...)
}
return combs
}
}
// func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) {
func (s *webpageRouter) handleTemplateError(err error, templateArgs ...any) (response.Response, error) {
if isFileNotFoundError(err) {
return nil, response.NotFound().
Wrap(err).
Msg("resource not found")
}
return nil, err
}
func isFileNotFoundError(err error) bool {
var pe *os.PathError
isPathErr := errors.As(err, &pe)
return isPathErr && pe.Err != nil && pe.Err.Error() == "no such file or directory"
}
func getHTTPStatusCode(err error) int {
rerr, ok := response.GetError(err)
if !ok {
return http.StatusInternalServerError
}
code, ok := rerr.GetStatus()
if !ok {
return http.StatusInternalServerError
}
return code
}
// template tooling
type URLCalculator struct {
url *url.URL
}
func newURLCalculator(u *url.URL) URLCalculator {
cpy := *u
return URLCalculator{
url: &cpy,
}
}
func (c URLCalculator) SetQueryParam(k string, v any) string {
u := *c.url
q := u.Query()
q.Set(k, fmt.Sprint(v))
u.RawQuery = q.Encode()
return u.String()
}
// template authenticator
type templateAuthenticator struct {
req *http.Request
}
func newTemplateAuthenticator(req *http.Request) *templateAuthenticator {
return &templateAuthenticator{
req: req,
}
}
// templateAuthorizationFunc these shouild always return an empty string
type templateAuthorizationFunc = func() (string, error)
func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) {
return "", authorizeByMatchingAccountID(a.req, acctIDPathPosition)
}
func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error {
pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/")
if len(pathParts) < acctIDPathPosition {
return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path)
}
part := pathParts[acctIDPathPosition-1]
acctID, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return response.NotFound().
Msgf("account does not exist: %s", part)
}
id := middleware.GetIdentity(r.Context())
if id.Account == nil || id.Account.AccountID != acctID {
return response.Unauthorized().
Msgf("user does not have access to account %d", acctID)
}
return nil
}