package web import ( "context" "errors" "fmt" "log/slog" "net/http" "net/url" "strconv" "strings" "syscall" "time" _ "time/tzdata" "gl/application/explorer" "gl/domain/ledger" "github.com/a-h/templ" ) type Explorer interface { Dashboard(context.Context) (explorer.Dashboard, error) Assets(context.Context) (explorer.Assets, error) Transactions(context.Context, explorer.TransactionFilter) (explorer.TransactionListing, error) Transaction(context.Context, string) ([]ledger.Journal, error) Account(context.Context, ledger.AccountReference) (explorer.Account, error) Holder(context.Context, explorer.HolderReference) (explorer.HolderAccount, error) } type Handler struct { explorer Explorer routes *http.ServeMux } type TransactionPageData struct { Query string Journals []ledger.Journal Listing *explorer.TransactionListing Error string } type AccountInput struct { Class string OwnerType string OwnerID string AssetID string } type AccountPageData struct { Input AccountInput Account *explorer.Account Error string } type HolderPageData struct { Account *explorer.HolderAccount Error string } func NewHandler(service Explorer) *Handler { handler := &Handler{explorer: service, routes: http.NewServeMux()} handler.routes.HandleFunc("GET /favicon.ico", handler.favicon) handler.routes.HandleFunc("GET /theme.js", handler.theme) handler.routes.HandleFunc("GET /{$}", handler.dashboard) handler.routes.HandleFunc("GET /assets", handler.assets) handler.routes.HandleFunc("GET /transactions", handler.transaction) handler.routes.HandleFunc("GET /transactions/{reference}", handler.transaction) handler.routes.HandleFunc("GET /accounts", handler.account) handler.routes.HandleFunc("GET /holders", handler.holder) return handler } func (h *Handler) assets(response http.ResponseWriter, request *http.Request) { locale := localeForRequest(response, request) data, err := h.explorer.Assets(request.Context()) if err != nil { h.renderFailure(response, request, locale, tr(locale, "assets_unavailable"), err) return } h.render(response, request, http.StatusOK, locale, tr(locale, "title_assets"), "assets", AssetsContent(data, locale)) } func (h *Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) { response.Header().Set("X-Content-Type-Options", "nosniff") response.Header().Set("Referrer-Policy", "same-origin") response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'") h.routes.ServeHTTP(response, request) } func (h *Handler) dashboard(response http.ResponseWriter, request *http.Request) { locale := localeForRequest(response, request) data, err := h.explorer.Dashboard(request.Context()) if err != nil { h.renderFailure(response, request, locale, tr(locale, "dashboard_unavailable"), err) return } h.render(response, request, http.StatusOK, locale, tr(locale, "title_overview"), "dashboard", DashboardContent(data, locale)) } func (h *Handler) transaction(response http.ResponseWriter, request *http.Request) { locale := localeForRequest(response, request) query := strings.TrimSpace(request.PathValue("reference")) if query == "" { query = strings.TrimSpace(request.URL.Query().Get("q")) } data := TransactionPageData{Query: query} showLatest := query == "" if query != "" { journals, err := h.explorer.Transaction(request.Context(), query) switch { case err == nil: data.Journals = journals case requestEnded(request, err): return case explorer.IsNotFound(err): data.Error = tr(locale, "transaction_missing") showLatest = true default: data.Error = tr(locale, "transaction_lookup_error") } } if showLatest { page, parseErr := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get("page"))) if parseErr != nil || page < 1 { page = 1 } listing, err := h.explorer.Transactions(request.Context(), explorer.TransactionFilter{ Page: page, Wallet: request.URL.Query().Get("wallet"), EffectKind: request.URL.Query().Get("effect"), }) if requestEnded(request, err) { return } else if err != nil { h.renderFailure(response, request, locale, tr(locale, "transactions_unavailable"), err) return } data.Listing = &listing } h.render(response, request, http.StatusOK, locale, tr(locale, "title_transactions"), "transactions", TransactionContent(data, locale)) } func (h *Handler) account(response http.ResponseWriter, request *http.Request) { locale := localeForRequest(response, request) data := AccountPageData{Input: AccountInput{ Class: strings.TrimSpace(request.URL.Query().Get("class")), OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")), OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")), AssetID: strings.TrimSpace(request.URL.Query().Get("asset_id")), }} if request.URL.Query().Has("class") { assetID, err := strconv.ParseInt(data.Input.AssetID, 10, 64) if err != nil || assetID <= 0 { data.Error = tr(locale, "asset_positive") } else { result, lookupErr := h.explorer.Account(request.Context(), ledger.AccountReference{ Class: ledger.AccountClass(data.Input.Class), OwnerType: data.Input.OwnerType, OwnerID: data.Input.OwnerID, AssetID: assetID, }) if requestEnded(request, lookupErr) { return } else if lookupErr != nil { data.Error = tr(locale, "account_lookup_error") } else { data.Account = &result } } } h.render(response, request, http.StatusOK, locale, tr(locale, "title_accounts"), "accounts", AccountContent(data, locale)) } func (h *Handler) holder(response http.ResponseWriter, request *http.Request) { locale := localeForRequest(response, request) data := HolderPageData{} assetID, err := strconv.ParseInt(strings.TrimSpace(request.URL.Query().Get("asset_id")), 10, 64) reference := explorer.HolderReference{ OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")), OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")), AssetID: assetID, } if err != nil || reference.OwnerType == "" || reference.OwnerID == "" || reference.AssetID <= 0 { data.Error = tr(locale, "holder_reference_invalid") } else { result, lookupErr := h.explorer.Holder(request.Context(), reference) if requestEnded(request, lookupErr) { return } else if lookupErr != nil { data.Error = tr(locale, "account_lookup_error") } else { data.Account = &result } } h.render(response, request, http.StatusOK, locale, tr(locale, "title_holder"), "assets", HolderContent(data, locale)) } func (h *Handler) render(response http.ResponseWriter, request *http.Request, status int, locale Locale, title, active string, content templ.Component) { component := Page(title, active, locale, localeURL(request, LocaleEnglish), localeURL(request, LocalePersian), content) if request.Header.Get("HX-Request") == "true" && request.Header.Get("HX-History-Restore-Request") != "true" { component = content } response.Header().Set("Content-Type", "text/html; charset=utf-8") response.WriteHeader(status) if err := component.Render(request.Context(), response); err != nil { if requestEnded(request, err) { return } slog.Error("render explorer", "error", err) } } func (h *Handler) renderFailure(response http.ResponseWriter, request *http.Request, locale Locale, title string, err error) { if requestEnded(request, err) { return } slog.Error("explorer request failed", "error", err) h.render(response, request, http.StatusInternalServerError, locale, title, "", FailureContent(title, tr(locale, "read_model_unavailable"), locale)) } func requestEnded(request *http.Request, err error) bool { return request.Context().Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) } func accountClasses() []ledger.AccountClass { return []ledger.AccountClass{ ledger.AccountClassUserAvailable, ledger.AccountClassUserFrozen, ledger.AccountClassExternalBlockchain, ledger.AccountClassTreasury, ledger.AccountClassMarketClearing, ledger.AccountClassIPGClearing, ledger.AccountClassCommissionRevenue, } } func formatTime(value time.Time) string { if value.IsZero() || value.Unix() == 0 { return "—" } return value.In(defaultTimezone).Format("02 Jan 2006 · 15:04:05") + " Asia/Tehran" } func short(value string, size int) string { if len(value) <= size { return value } left := size / 2 return value[:left] + "…" + value[len(value)-(size-left):] } func count(value int64) string { text := strconv.FormatInt(value, 10) for index := len(text) - 3; index > 0; index -= 3 { text = text[:index] + "," + text[index:] } return text } func transactionName(journal ledger.Journal, locale Locale) string { if journal.Blockchain.TransactionHash != "" { return short(journal.Blockchain.TransactionHash, 22) } return tr(locale, "internal") + " · " + short(journal.ID, 14) } func transactionReference(journal ledger.Journal) string { if journal.Blockchain.TransactionHash != "" { return journal.Blockchain.TransactionHash } return journal.ID } func transactionURL(reference string) string { return "/transactions/" + url.PathEscape(reference) } func transactionPageURL(filter explorer.TransactionFilter, page int) string { values := url.Values{"page": []string{strconv.Itoa(page)}} if filter.Wallet != "" { values.Set("wallet", filter.Wallet) } if filter.EffectKind != "" { values.Set("effect", filter.EffectKind) } return "/transactions?" + values.Encode() } func accountURL(account ledger.AccountReference) string { values := url.Values{ "class": []string{string(account.Class)}, "owner_type": []string{account.OwnerType}, "owner_id": []string{account.OwnerID}, "asset_id": []string{strconv.FormatInt(account.AssetID, 10)}, } return "/accounts?" + values.Encode() } func holderURL(holder explorer.Holder) string { values := url.Values{ "owner_type": []string{holder.OwnerType}, "owner_id": []string{holder.OwnerID}, "asset_id": []string{strconv.FormatInt(holder.AssetID, 10)}, } return "/holders?" + values.Encode() } func accountName(account ledger.AccountReference, locale Locale) string { owner := account.OwnerID if owner == "" { owner = tr(locale, "system") } return fmt.Sprintf("%s / %s", account.Class, owner) }