Files
inventory-plus-plus/server/ui/router.go
T
angel 82efe19ae0
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
prototyped svg reports
2026-08-19 23:19:41 -06:00

377 lines
8.6 KiB
Go

package ui
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/angelbeltran/templater"
"github.com/gin-gonic/gin"
"ruben/inventory2/domains/accounts"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/domains/reports"
"ruben/inventory2/logging"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/response"
)
type (
webpageRouter struct {
log *logging.Logger
uiPath string
templater *templater.Templater
rawEvents *raw_events.Store
accts *accounts.Store
reports *reports.Store
etsy *etsy_platform.Platform
}
// ErrTemplateNotFound is returned if the reason the template failed to compile
// is due to the template not being found.
ErrTemplateNotFound struct {
err error
}
)
func Routes(
logger *logging.Logger,
r gin.IRouter,
uiPath string,
rawEvents *raw_events.Store,
accts *accounts.Store,
reps *reports.Store,
etsy *etsy_platform.Platform,
authenticate gin.HandlerFunc,
) {
s := &webpageRouter{
log: logger,
uiPath: uiPath,
templater: new(templater.Templater).With(templater.Config{
Funcs: func(name string, props map[string]any) template.FuncMap {
return template.FuncMap{
// parsing
"parseInt": func(s string) (int, error) {
return strconv.Atoi(s)
},
"parseInt64": func(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
},
"parsePlatform": func(s string) (accounts.Platform, error) {
return accounts.NewPlatform(s)
},
// strings
"lowerSnakeCase": func(s string) string {
return strings.ToLower(strings.Join(strings.Split(s, " "), "_"))
},
"splitSnakeCase": func(s string) string {
return strings.Join(strings.Split(s, "_"), " ")
},
"capitalize": func(s string) string {
ss := strings.Split(s, " ")
us := make([]string, len(ss))
for i, v := range ss {
if len(v) == 0 {
us[i] = v
} else {
us[i] = strings.ToUpper(v[:1]) + v[1:]
}
}
return strings.Join(us, " ")
},
"hasPrefix": func(s, prefix string) bool {
return strings.HasPrefix(s, prefix)
},
// arithmetic
"addInt": func(a, b int) int {
return a + b
},
"subInt": func(a, b int) int {
return a - b
},
"multInt": func(a, b int) int {
return a * b
},
"divInt": func(a, b int) int {
return a / b
},
"intToFloat": func(n int) float64 {
return float64(n)
},
"addInt64": func(a, b int64) int64 {
return a + b
},
"subInt64": func(a, b int64) int64 {
return a - b
},
"multInt64": func(a, b int64) int64 {
return a * b
},
"divInt64": func(a, b int64) int64 {
return a / b
},
"int64ToFloat": func(n int64) float64 {
return float64(n)
},
"addFloat": func(a, b float64) float64 {
return a + b
},
"subFloat": func(a, b float64) float64 {
return a - b
},
"multFloat": func(a, b float64) float64 {
return a * b
},
"divFloat": func(a, b float64) float64 {
return a / b
},
// html
"rawHTML": func(s string) template.HTML {
return template.HTML(s)
},
"rawHTMLAttr": func(s string) template.HTMLAttr {
return template.HTMLAttr(s)
},
"style": func(kvs ...string) (template.HTMLAttr, error) {
if len(kvs)%2 != 0 {
return "", fmt.Errorf("expected an even number of keys: %d", len(kvs))
}
parts := make([]string, len(kvs)/2)
for i := range parts {
parts[i] = fmt.Sprintf("%s: %s;", kvs[2*i], kvs[2*i+1])
}
return template.HTMLAttr(strings.Join(parts, " ")), nil
},
// json
"prettyPrintJSON": func(j json.RawMessage) string {
b, err := json.MarshalIndent(j, " ", "")
if err != nil {
return string(j)
}
return string(b)
},
"marshalJSON": json.Marshal,
// slices
"newSlice": func(args ...any) []any {
return args
},
"newHTMLSlice": func(args ...string) []template.HTML {
ss := make([]template.HTML, len(args))
for i, s := range args {
ss[i] = template.HTML(s)
}
return ss
},
}
},
}),
rawEvents: rawEvents,
accts: accts,
reports: reps,
etsy: etsy,
}
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate))
r.GET("/*rest", authenticate, response.Handler(s.serveTemplate))
}
func (s *webpageRouter) redirectToAccountsIfLoggedInWithAnAccount(c *gin.Context) (response.Response, error) {
id := auth.GetIdentity(c)
if id.Account == nil || id.Account.AccountID == 0 {
return nil, nil
}
return response.TemporaryRedirect(fmt.Sprintf("%s/accounts/%d", s.uiPath, id.Account.AccountID)), nil
}
// GET /
// compiles the page template or component template matching the url
func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error) {
// trim the url path prefix before loading the templates
c.Request.URL.Path = c.Request.URL.Path[len(s.uiPath):]
defer func() {
c.Request.URL.Path = s.uiPath + c.Request.URL.Path
}()
r := c.Request
ctx := r.Context()
id := auth.GetIdentity(ctx)
var mockMode bool
if id.Account != nil {
var err error
if mockMode, err = s.accts.GetMockMode(ctx, id.Account.AccountID); err != nil {
return nil, err
}
}
args := []any{
"Request",
r,
// add services and data here
"RawEvents",
s.rawEvents.WithContext(ctx),
"URLCalc",
newURLCalculator(r.URL),
"Accounts",
s.accts.WithContext(ctx),
"Reports",
s.reports.WithContext(ctx),
"Etsy",
s.etsy.WithContext(ctx),
"MockMode",
mockMode,
// auth tooling
"Identity",
id,
"Auth",
newTemplateAuthenticator(r),
}
b, err := s.templater.Execute(strings.Trim(r.URL.Path, "/"), args...)
if err != nil {
if isFileNotFoundError(err) || isInvalidWildcardValue(err) {
werr := response.NotFound().
Wrap(ErrTemplateNotFound{
err: err,
}).
Msg("resource not found")
nfb, nferr := s.templater.Execute("not-found", args...)
if nferr != nil {
s.log.Errorf("failed to render not-found page: %v", nferr)
return nil, werr
}
return nil, werr.
HTML(nfb)
}
return nil, err
}
return response.HTML(b), nil
}
func isInvalidWildcardValue(err error) bool {
var te *templater.ErrInvalidWildcardValue
return errors.As(err, &te)
}
func isFileNotFoundError(err error) bool {
var te *templater.ErrNotTemplateFileFound
return errors.As(err, &te)
}
// 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 := auth.GetIdentity(r.Context())
if id.Account == nil || id.Account.AccountID != acctID {
return response.Unauthorized().
HTML([]byte(fmt.Sprintf(`
<section class="text-center">
<header class="m-[1em]">
<h2>
Access Not Granted
</h2>
<a href="/ui%s" class="block m-[1em]">
<code>
/ui%s
</code>
</a>
</header>
<div class="m-[2em]">
<p class="italic font-bold">
Sorry, you don't have access to the given page.
</p>
</div>
</section>
<section class="text-center">
<a href="/">
Return to app
</a>
</section>
`, r.URL, r.URL)))
}
return nil
}
func (e ErrTemplateNotFound) Error() string {
if e.err != nil {
return fmt.Sprintf("template not found: %v", e.err)
}
return fmt.Sprintf("template not found")
}
func (e ErrTemplateNotFound) Unwrap() error {
return e.err
}