319 lines
10 KiB
Go
319 lines
10 KiB
Go
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)
|
|
}
|