Compare commits

...

13 commits

Author SHA1 Message Date
b68f626ce2
refactor!: Use function method for routes
Will be useful for future development using standard `net/http`.
2024-11-06 22:15:53 +07:00
2e31824910
fix!: Redirect old /remote-nodes/logs to /remote-nodes/id/{id} #155
The old `/remote-nodes/logs/?node_id={id}` is not being used anymore
and should be redirected to the new path: `/remote-nodes/id/{id}`.

Remove the route once search engines result shows the new path
2024-11-06 21:34:05 +07:00
5fb88865d0
test: Added test for validTorHostname #149 2024-11-06 20:52:09 +07:00
f227371fa6
fix: Allow tor address with subdomain #149 2024-11-06 20:47:34 +07:00
3f5c2b9905
feat: Added TOR address validation #149 2024-11-06 20:21:15 +07:00
df161f831a
feat: Added info block in remote-nodes page
Also move the table right after page title and description, so users
doesn't need to scroll down to view the table.
2024-11-06 20:03:28 +07:00
75e97b4e0c
style: Styling remote-nodes hero hr divider 2024-11-06 19:35:31 +07:00
9e1da3c79a
style: Added link css class to internal URL 2024-11-06 19:32:34 +07:00
0f011572f5
chore: Updated the Monero Node block info detail 2024-11-06 19:17:10 +07:00
3beb3ba60e
feat: Added permalink header 2024-11-06 18:00:25 +07:00
fb6f6c2b5c
feat: Convert DatabaseSize and Difficulty to human readable format 2024-11-06 17:34:41 +07:00
1eb26210f6
refactor: Moving internal/views/utils.go to ./utils 2024-11-06 17:11:16 +07:00
95b371a056
feat! Added monero node details page and logs 2024-11-06 16:45:34 +07:00
16 changed files with 1170 additions and 320 deletions

View file

@ -12,6 +12,7 @@ IPV6_CAPABLE=false
# Server Config
# #############
APP_URL="https://xmr.ditatompel.com" # URL where user can access the web UI, don't put trailing slash
# Fiber Config
APP_PREFORK=false

View file

@ -13,6 +13,9 @@ type App struct {
LogLevel string
// configuration for server
URL string // URL where user can access the web UI, don't put trailing slash
// fiber specific config
Prefork bool
Host string
Port int
@ -55,6 +58,9 @@ func LoadApp() {
}
// server configuration
app.URL = os.Getenv("APP_URL")
// fiber specific config
app.Host = os.Getenv("APP_HOST")
app.Port, _ = strconv.Atoi(os.Getenv("APP_PORT"))
app.Prefork, _ = strconv.ParseBool(os.Getenv("APP_PREFORK"))

View file

@ -6,7 +6,8 @@ import (
"github.com/gofiber/fiber/v2"
)
func CheckProber(c *fiber.Ctx) error {
// checkProberMW is a middleware to check prober API key
func (s *fiberServer) checkProberMW(c *fiber.Ctx) error {
key := c.Get(monero.ProberAPIKey)
if key == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{

View file

@ -13,6 +13,19 @@ import (
"github.com/gofiber/fiber/v2/middleware/adaptor"
)
// Redirect old `/remote-nodes/logs/?node_id={id}` path to `/remote-nodes/id/{id}`
//
// This is temporary handler to redirect old path to new one. Once search
// engine results updated to the new path, this handler should be removed.
func (s *fiberServer) redirectLogs(c *fiber.Ctx) error {
id := c.QueryInt("node_id", 0)
if id == 0 {
return c.Redirect("/remote-nodes", fiber.StatusMovedPermanently)
}
return c.Redirect(fmt.Sprintf("/remote-nodes/id/%d", id), fiber.StatusMovedPermanently)
}
// Render Home Page
func (s *fiberServer) homeHandler(c *fiber.Ctx) error {
p := views.Meta{
@ -20,7 +33,7 @@ func (s *fiberServer) homeHandler(c *fiber.Ctx) error {
Description: "A website that helps you monitor your favourite Monero remote nodes, but YOU BETTER RUN AND USE YOUR OWN NODE.",
Keywords: "monero,monero,xmr,monero node,xmrnode,cryptocurrency,monero remote node,monero testnet,monero stagenet",
Robots: "INDEX,FOLLOW",
Permalink: "https://xmr.ditatompel.com",
Permalink: s.url,
Identifier: "/",
}
@ -61,7 +74,7 @@ func (s *fiberServer) addNodeHandler(c *fiber.Ctx) error {
Description: "You can use this page to add known remote node to the system so my bots can monitor it.",
Keywords: "monero,monero node,monero public node,monero wallet,list monero node,monero node monitoring",
Robots: "INDEX,FOLLOW",
Permalink: "https://xmr.ditatompel.com/add-node",
Permalink: s.url + "/add-node",
Identifier: "/add-node",
}
@ -72,8 +85,8 @@ func (s *fiberServer) addNodeHandler(c *fiber.Ctx) error {
return handler(c)
}
// Returns a single node information based on `id` query param
func Node(c *fiber.Ctx) error {
// Returns a single node information based on `id` query param (API endpoint, JSON data)
func (s *fiberServer) nodeAPI(c *fiber.Ctx) error {
nodeId, err := c.ParamsInt("id", 0)
if err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
@ -115,7 +128,7 @@ func (s *fiberServer) remoteNodesHandler(c *fiber.Ctx) error {
Description: "Although it's possible to use these existing public Monero nodes, you're MUST RUN AND USE YOUR OWN NODE!",
Keywords: "monero remote nodes,public monero nodes,monero public nodes,monero wallet,tor monero node,monero cors rpc",
Robots: "INDEX,FOLLOW",
Permalink: "https://xmr.ditatompel.com/remote-nodes",
Permalink: s.url + "/remote-nodes",
Identifier: "/remote-nodes",
}
@ -171,49 +184,88 @@ func (s *fiberServer) remoteNodesHandler(c *fiber.Ctx) error {
}
// Returns a single node information based on `id` query param.
// For now, only process from HTMX request.
// This used for node modal and node details page including node probe logs.
func (s *fiberServer) nodeHandler(c *fiber.Ctx) error {
nodeID, err := c.ParamsInt("id", 0)
if err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
if nodeID == 0 {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"status": "error",
"message": "Invalid node id",
"data": nil,
})
}
moneroRepo := monero.New()
node, err := moneroRepo.Node(nodeID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
switch c.Get("HX-Target") {
case "modal-section":
nodeID, err := c.ParamsInt("id", 0)
if err != nil {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
if nodeID == 0 {
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
"status": "error",
"message": "Invalid node id",
"data": nil,
})
}
moneroRepo := monero.New()
node, err := moneroRepo.Node(nodeID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
cmp := views.ModalLayout(fmt.Sprintf("Node #%d", nodeID), views.Node(node))
handler := adaptor.HTTPHandler(templ.Handler(cmp))
return handler(c)
}
// for now, just return 400
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"status": "error",
"message": "Bad Request, invalid HTMX request",
"data": nil,
})
queryLogs := monero.QueryLogs{
Paging: paging.Paging{
Limit: c.QueryInt("limit", 10), // rows per page
Page: c.QueryInt("page", 1),
SortBy: c.Query("sort_by", "id"),
SortDirection: c.Query("sort_direction", "desc"),
Refresh: c.Query("refresh"),
},
NodeID: int(node.ID),
Status: c.QueryInt("status", -1),
FailedReason: c.Query("failed_reason"),
}
logs, err := moneroRepo.Logs(queryLogs)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"status": "error",
"message": err.Error(),
"data": nil,
})
}
pagination := paging.NewPagination(queryLogs.Page, logs.TotalPages)
// handle datatable logs filters, sort request from HTMX
if c.Get("HX-Target") == "tbl_logs" {
cmp := views.BlankLayout(views.TableLogs(fmt.Sprintf("/remote-nodes/id/%d", node.ID), logs, queryLogs, pagination))
handler := adaptor.HTTPHandler(templ.Handler(cmp))
return handler(c)
}
p := views.Meta{
Title: fmt.Sprintf("%s on Port %d", node.Hostname, node.Port),
Description: fmt.Sprintf("Monero %s remote node %s running on port %d", node.Nettype, node.Hostname, node.Port),
Keywords: fmt.Sprintf("monero log,monero node log,monitoring monero log,monero,xmr,monero node,xmrnode,cryptocurrency,monero %s,%s", node.Nettype, node.Hostname),
Robots: "INDEX,FOLLOW",
Permalink: s.url + "/remote-nodes/id/" + strconv.Itoa(int(node.ID)),
Identifier: "/remote-nodes",
}
c.Set("Link", fmt.Sprintf(`<%s>; rel="canonical"`, p.Permalink))
cmp := views.BaseLayout(p, views.NodeDetails(node, logs, queryLogs, pagination))
handler := adaptor.HTTPHandler(templ.Handler(cmp))
return handler(c)
}
// Returns a list of nodes (API)
func Nodes(c *fiber.Ctx) error {
// Returns list of nodes (API endpoint, JSON data)
func (s *fiberServer) nodesAPI(c *fiber.Ctx) error {
moneroRepo := monero.New()
query := monero.QueryNodes{
Paging: paging.Paging{
@ -247,19 +299,20 @@ func Nodes(c *fiber.Ctx) error {
})
}
// Returns probe logs reported by nodes
//
// The embadded web UI use `node_id` query param to filter logs
func ProbeLogs(c *fiber.Ctx) error {
// Returns probe logs reported by nodes (API endpoint, JSON data)
func (s *fiberServer) probeLogsAPI(c *fiber.Ctx) error {
moneroRepo := monero.New()
query := monero.QueryLogs{
RowsPerPage: c.QueryInt("limit", 10),
Page: c.QueryInt("page", 1),
SortBy: c.Query("sort_by", "id"),
SortDirection: c.Query("sort_direction", "desc"),
NodeID: c.QueryInt("node_id", 0),
Status: c.QueryInt("status", -1),
FailedReason: c.Query("failed_reason"),
Paging: paging.Paging{
Limit: c.QueryInt("limit", 10), // rows per page
Page: c.QueryInt("page", 1),
SortBy: c.Query("sort_by", "id"),
SortDirection: c.Query("sort_direction", "desc"),
Refresh: c.Query("refresh"),
},
NodeID: c.QueryInt("node_id", 0),
Status: c.QueryInt("status", -1),
FailedReason: c.Query("failed_reason"),
}
logs, err := moneroRepo.Logs(query)
@ -281,7 +334,7 @@ func ProbeLogs(c *fiber.Ctx) error {
// Handles `POST /nodes` request to add a new node
//
// Deprecated: AddNode is deprecated, use s.addNodeHandler with put method instead
func AddNode(c *fiber.Ctx) error {
func (s *fiberServer) addNodeAPI(c *fiber.Ctx) error {
formPort := c.FormValue("port")
port, err := strconv.Atoi(formPort)
if err != nil {
@ -311,8 +364,8 @@ func AddNode(c *fiber.Ctx) error {
})
}
// Returns majority network fees
func NetFees(c *fiber.Ctx) error {
// Returns majority network fees (API endpoint, JSON data)
func (s *fiberServer) netFeesAPI(c *fiber.Ctx) error {
moneroRepo := monero.New()
return c.JSON(fiber.Map{
"status": "ok",
@ -321,8 +374,8 @@ func NetFees(c *fiber.Ctx) error {
})
}
// Returns list of countries (count by nodes)
func Countries(c *fiber.Ctx) error {
// Returns list of countries, count by nodes (API endpoint, JSON data)
func (s *fiberServer) countriesAPI(c *fiber.Ctx) error {
moneroRepo := monero.New()
countries, err := moneroRepo.Countries()
if err != nil {
@ -339,10 +392,10 @@ func Countries(c *fiber.Ctx) error {
})
}
// Returns node to be probed by the client (prober)
// Returns node to be probed by the prober (API endpoint, JSON data)
//
// This handler should protected by `CheckProber` middleware.
func GiveJob(c *fiber.Ctx) error {
// This handler should protected by `s.checkProberMW` middleware.
func (s *fiberServer) giveJobAPI(c *fiber.Ctx) error {
acceptTor := c.QueryInt("accept_tor", 0)
acceptIPv6 := c.QueryInt("accept_ipv6", 0)
@ -363,10 +416,10 @@ func GiveJob(c *fiber.Ctx) error {
})
}
// Handles probe report submission by the prober
// Handles probe report submission by the prober (API endpoint, JSON data)
//
// This handler should protected by `CheckProber` middleware.
func ProcessJob(c *fiber.Ctx) error {
func (s *fiberServer) processJobAPI(c *fiber.Ctx) error {
var report monero.ProbeReport
if err := c.BodyParser(&report); err != nil {

View file

@ -7,18 +7,22 @@ func (s *fiberServer) Routes() {
s.App.Get("/add-node", s.addNodeHandler)
s.App.Put("/add-node", s.addNodeHandler)
// This is temporary route to redirect old path to new one. Once search
// engine results updated to the new path, this route should be removed.
s.App.Get("/remote-nodes/logs", s.redirectLogs)
// V1 API routes
v1 := s.App.Group("/api/v1")
// these routes are public, they don't require a prober api key
v1.Get("/nodes", Nodes)
v1.Post("/nodes", AddNode) // old add node form action endpoint. Deprecated: Use PUT /add-node instead
v1.Get("/nodes/id/:id", Node)
v1.Get("/nodes/logs", ProbeLogs)
v1.Get("/fees", NetFees)
v1.Get("/countries", Countries)
v1.Get("/nodes", s.nodesAPI)
v1.Post("/nodes", s.addNodeAPI) // old add node form action endpoint. Deprecated: Use PUT /add-node instead
v1.Get("/nodes/id/:id", s.nodeAPI)
v1.Get("/nodes/logs", s.probeLogsAPI)
v1.Get("/fees", s.netFeesAPI)
v1.Get("/countries", s.countriesAPI)
// these routes are for prober, they require a prober api key
v1.Get("/job", CheckProber, GiveJob)
v1.Post("/job", CheckProber, ProcessJob)
v1.Get("/job", s.checkProberMW, s.giveJobAPI)
v1.Post("/job", s.checkProberMW, s.processJobAPI)
}

View file

@ -8,7 +8,8 @@ import (
type fiberServer struct {
*fiber.App
db *database.DB
db *database.DB
url string
}
// NewServer returns a new fiber server
@ -22,7 +23,8 @@ func NewServer() *fiberServer {
ProxyHeader: config.AppCfg().ProxyHeader,
AppName: "XMR Nodes Aggregator " + config.Version,
}),
db: database.GetDB(),
db: database.GetDB(),
url: config.AppCfg().URL,
}
return server

View file

@ -66,7 +66,7 @@ templ AddNode() {
<div id="form-result" class="max-w-4xl mx-auto my-6"></div>
<div class="mt-3 text-center">
<p class="text-sm text-gray-500 dark:text-neutral-500">
Existing remote nodes can be found in <a href="/remote-nodes">/remote-nodes</a> page.
Existing remote nodes can be found in <a href="/remote-nodes" class="link">/remote-nodes</a> page.
</p>
</div>
</div>

View file

@ -37,7 +37,7 @@ func AddNode() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"relative z-10\"><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 lg:py-16\"><div class=\"text-center\"><!-- Title --><div class=\"mt-5\"><h1 class=\"block font-extrabold text-4xl md:text-5xl lg:text-6xl text-neutral-200\">Add Monero Node</h1></div><!-- End Title --><div class=\"mt-5\"><p class=\"text-lg text-neutral-300\">You can use this page to add known remote node to the system so my bots can monitor it.</p></div></div><hr class=\"my-6 border-orange-400 mx-auto max-w-3xl\"><div class=\"max-w-4xl mx-auto px-4\"><div class=\"p-4 bg-blue-800/10 border border-blue-900 text-sm text-white rounded-lg\" role=\"alert\" tabindex=\"-1\" aria-labelledby=\"add-node-notice\"><div class=\"flex\"><div class=\"ms-4\"><h2 id=\"add-node-notice\" class=\"text-xl font-bold text-center\">Important Note</h2><div class=\"mt-2 text-sm\"><ul class=\"list-disc space-y-1 ps-5\"><li>As an administrator of this instance, I have full rights to delete, and blacklist any submitted node with or without providing any reason.</li></ul></div></div></div></div></div><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6\"><p class=\"mt-1 text-center\">Enter your Monero node information below (IPv6 host check is experimental):</p><div class=\"mt-12\"><form method=\"put\" hx-swap=\"transition:true\" hx-target=\"#form-result\" hx-disabled-elt=\".form\" hx-on::after-request=\"this.reset()\"><div class=\"grid grid-cols-1 sm:grid-cols-4 gap-6\"><div><label for=\"protocol\" class=\"block text-neutral-200\">Protocol *</label> <select id=\"protocol\" name=\"protocol\" class=\"frameless form\" autocomplete=\"off\"><option value=\"http\">HTTP</option> <option value=\"https\">HTTPS</option></select></div><div class=\"md:col-span-2\"><label for=\"hostname\" class=\"block text-neutral-200\">Host / IP *</label> <input type=\"text\" name=\"hostname\" id=\"hostname\" class=\"frameless form\" autocomplete=\"off\" placeholder=\"Eg: node.example.com or 172.16.17.18\" required></div><div><label for=\"port\" class=\"block text-neutral-200\">Port *</label> <input type=\"text\" name=\"port\" id=\"port\" class=\"frameless form\" autocomplete=\"off\" placeholder=\"Eg: 18081\" required></div></div><div class=\"mt-6 grid\"><button type=\"submit\" class=\"form w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-bold rounded-lg border border-transparent bg-orange-600 text-white hover:bg-orange-500 focus:outline-none disabled:opacity-60 disabled:pointer-events-none\">Submit</button></div></form><div id=\"form-result\" class=\"max-w-4xl mx-auto my-6\"></div><div class=\"mt-3 text-center\"><p class=\"text-sm text-gray-500 dark:text-neutral-500\">Existing remote nodes can be found in <a href=\"/remote-nodes\">/remote-nodes</a> page.</p></div></div></div></div></div></section><!-- End Hero -->")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"relative z-10\"><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 lg:py-16\"><div class=\"text-center\"><!-- Title --><div class=\"mt-5\"><h1 class=\"block font-extrabold text-4xl md:text-5xl lg:text-6xl text-neutral-200\">Add Monero Node</h1></div><!-- End Title --><div class=\"mt-5\"><p class=\"text-lg text-neutral-300\">You can use this page to add known remote node to the system so my bots can monitor it.</p></div></div><hr class=\"my-6 border-orange-400 mx-auto max-w-3xl\"><div class=\"max-w-4xl mx-auto px-4\"><div class=\"p-4 bg-blue-800/10 border border-blue-900 text-sm text-white rounded-lg\" role=\"alert\" tabindex=\"-1\" aria-labelledby=\"add-node-notice\"><div class=\"flex\"><div class=\"ms-4\"><h2 id=\"add-node-notice\" class=\"text-xl font-bold text-center\">Important Note</h2><div class=\"mt-2 text-sm\"><ul class=\"list-disc space-y-1 ps-5\"><li>As an administrator of this instance, I have full rights to delete, and blacklist any submitted node with or without providing any reason.</li></ul></div></div></div></div></div><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6\"><p class=\"mt-1 text-center\">Enter your Monero node information below (IPv6 host check is experimental):</p><div class=\"mt-12\"><form method=\"put\" hx-swap=\"transition:true\" hx-target=\"#form-result\" hx-disabled-elt=\".form\" hx-on::after-request=\"this.reset()\"><div class=\"grid grid-cols-1 sm:grid-cols-4 gap-6\"><div><label for=\"protocol\" class=\"block text-neutral-200\">Protocol *</label> <select id=\"protocol\" name=\"protocol\" class=\"frameless form\" autocomplete=\"off\"><option value=\"http\">HTTP</option> <option value=\"https\">HTTPS</option></select></div><div class=\"md:col-span-2\"><label for=\"hostname\" class=\"block text-neutral-200\">Host / IP *</label> <input type=\"text\" name=\"hostname\" id=\"hostname\" class=\"frameless form\" autocomplete=\"off\" placeholder=\"Eg: node.example.com or 172.16.17.18\" required></div><div><label for=\"port\" class=\"block text-neutral-200\">Port *</label> <input type=\"text\" name=\"port\" id=\"port\" class=\"frameless form\" autocomplete=\"off\" placeholder=\"Eg: 18081\" required></div></div><div class=\"mt-6 grid\"><button type=\"submit\" class=\"form w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-bold rounded-lg border border-transparent bg-orange-600 text-white hover:bg-orange-500 focus:outline-none disabled:opacity-60 disabled:pointer-events-none\">Submit</button></div></form><div id=\"form-result\" class=\"max-w-4xl mx-auto my-6\"></div><div class=\"mt-3 text-center\"><p class=\"text-sm text-gray-500 dark:text-neutral-500\">Existing remote nodes can be found in <a href=\"/remote-nodes\" class=\"link\">/remote-nodes</a> page.</p></div></div></div></div></div></section><!-- End Hero -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -5,6 +5,7 @@ import (
"github.com/ditatompel/xmr-remote-nodes/internal/ip"
"github.com/ditatompel/xmr-remote-nodes/internal/monero"
"github.com/ditatompel/xmr-remote-nodes/internal/paging"
"github.com/ditatompel/xmr-remote-nodes/utils"
"strings"
"time"
)
@ -38,20 +39,43 @@ templ RemoteNodes(data monero.Nodes, countries []monero.Countries, q monero.Quer
<div class="mt-5">
<p class="text-lg text-neutral-300"><strong>Monero remote node</strong> is a device on the internet running the Monero software with full copy of the Monero blockchain that doesn't run on the same local machine where the Monero wallet is located.</p>
</div>
<hr class="mt-6"/>
</div>
<div class="max-w-3xl text-center mx-auto mt-8 prose prose-invert">
<p>Remote node can be used by people who, for their own reasons (usually because of hardware requirements, disk space, or technical abilities), cannot/don't want to run their own node and prefer to relay on one publicly available on the Monero network.</p>
<p>Using an open node will allow to make a transaction instantaneously, without the need to download the blockchain and sync to the Monero network first, but at the cost of the control over your privacy. the <strong>Monero community suggests to <span class="font-extrabold text-2xl underline decoration-double decoration-2 decoration-pink-500">always run and use your own node</span></strong> to obtain the maximum possible privacy and to help decentralize the network.</p>
</div>
</div>
</div>
</section>
<!-- End Hero -->
<div class="flex flex-col max-w-6xl mx-auto mb-10">
<section class="flex flex-col max-w-6xl mx-auto mb-10">
<div class="min-w-full inline-block align-middle">
@TableNodes(data, countries, q, p)
</div>
</section>
<section id="page-info" class="max-w-4xl mx-auto px-4 mb-10">
<div class="p-4 bg-blue-800/10 border border-blue-900 text-sm text-white rounded-lg" role="alert" tabindex="-1" aria-labelledby="add-node-notice">
<div class="flex">
<div class="ms-4">
<h2 id="add-node-notice" class="text-xl font-bold text-center">Info</h2>
<div class="mt-2 text-sm">
<ul class="list-disc space-y-1 ps-5">
<li>If you find any remote nodes that are strange or suspicious, please <a href="https://github.com/ditatompel/xmr-remote-nodes/issues" target="_blank" rel="noopener" class="external">open an issue on GitHub</a> for removal.</li>
<li>Uptime percentage calculated is the <strong>last 1 month</strong> uptime.</li>
<li><strong>Est. Fee</strong> here is just fee estimation / byte from <code class="code text-green-500 font-bold">get_fee_estimate</code> RPC call method.</li>
<li>Malicious actors who running remote nodes <a href="/assets/img/node-tx-fee.jpg" rel="noopener" class="link" hx-boost="false">still can return high fee only if you about to create a transactions</a>.</li>
<li><strong class="font-extrabold text-2xl underline decoration-double decoration-2 decoration-pink-500">The best and safest way is running your own node!</strong></li>
<li>Nodes with 0% uptime within 1 month with more than 300 check attempt will be removed. You can always add your node again latter.</li>
<li>You can filter remote node by selecting on <strong>nettype</strong>, <strong>protocol</strong>, <strong>country</strong>, <strong>tor</strong>, and <strong>online status</strong> option.</li>
<li>If you want to add more remote node, you can add them using <a href="/add-node" class="link">/add-node</a> page.</li>
<li>I deliberately cut the long Tor addresses, click the <span class="text-orange-300">👁 torhostname...</span> to see the full Tor address.</li>
<li>You can found larger remote nodes database from <a href="https://monero.fail/" target="_blank" rel="noopener" class="external">monero.fail</a>.</li>
<li>If you are developer or power user who like to fetch Monero remote node above in JSON format, you can read <a href="https://insights.ditatompel.com/en/blog/2022/01/public-api-monero-remote-node-list/" class="external">Public API Monero Remote Node List</a> blog post for more detailed information.</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<div class="max-w-4xl text-center mx-auto my-10 prose prose-invert">
<p>Remote node can be used by people who, for their own reasons (usually because of hardware requirements, disk space, or technical abilities), cannot/don't want to run their own node and prefer to relay on one publicly available on the Monero network.</p>
<p>Using an open node will allow to make a transaction instantaneously, without the need to download the blockchain and sync to the Monero network first, but at the cost of the control over your privacy. the <strong>Monero community suggests to <span class="font-extrabold underline decoration-double decoration-2 decoration-pink-500">always run and use your own node</span></strong> to obtain the maximum possible privacy and to help decentralize the network.</p>
</div>
}
@ -210,8 +234,10 @@ templ TableNodes(data monero.Nodes, countries []monero.Countries, q monero.Query
<td class="text-right">{ fmt.Sprintf("%d", row.EstimateFee) }</td>
<td class="text-right">
@cellUptime(row.Uptime)
<br/>
<a href={ templ.URL(fmt.Sprintf("/remote-nodes/id/%d", row.ID)) } class="link">[Logs]</a>
</td>
<td title={ time.Unix(row.LastChecked, 0).UTC().Format("Jan 2, 2006 15:04 MST") }>{ timeSince(row.LastChecked) }</td>
<td title={ time.Unix(row.LastChecked, 0).UTC().Format("Jan 2, 2006 15:04 MST") }>{ utils.TimeSince(row.LastChecked) }</td>
</tr>
}
</tbody>
@ -225,13 +251,182 @@ templ TableNodes(data monero.Nodes, countries []monero.Countries, q monero.Query
}
templ Node(data monero.Node) {
<p>{ fmt.Sprintf("%s:%d", data.Hostname, data.Port) }</p>
<div class="space-y-3 text-neutral-200">
<dl class="flex flex-col sm:flex-row gap-1">
<dt class="min-w-40">
<span class="block text-white text-bold">Host:</span>
</dt>
<dd>
<ul>
<li class="me-1 inline-flex items-center">
{ fmt.Sprintf("%s:%d", data.Hostname, data.Port) }
</li>
<li class="me-1 inline-flex items-center">
<button type="button" class="clipboard px-2 inline-flex items-center gap-x-2 text-sm font-bold rounded-lg border border-transparent bg-orange-600 text-white hover:bg-orange-500 focus:outline-none disabled:opacity-60 disabled:pointer-events-none" data-clipboard-text={ fmt.Sprintf("%s:%d", data.Hostname, data.Port) }>Copy</button>
</li>
</ul>
</dd>
</dl>
<dl class="flex flex-col sm:flex-row gap-1">
<dt class="min-w-40">
<span class="block text-white text-bold">Net Type:</span>
</dt>
<dd>
<ul>
<li class="uppercase">{ data.Nettype }</li>
</ul>
</dd>
</dl>
<dl class="flex flex-col sm:flex-row gap-1">
<dt class="min-w-40">
<span class="block text-white text-bold">Protocol:</span>
</dt>
<dd>
<ul>
<li class="uppercase">{ data.Protocol }</li>
</ul>
</dd>
</dl>
<dl class="flex flex-col sm:flex-row gap-1">
<dt class="min-w-40">
<span class="block text-white text-bold">IP Addresses:</span>
</dt>
<dd>
<ul>
<li class="whitespace-break-spaces">{ strings.ReplaceAll(data.IPAddresses, ",", ", ") }</li>
</ul>
</dd>
</dl>
</div>
}
templ NodeDetails(data monero.Node, logs monero.FetchLogs, q monero.QueryLogs, p paging.Pagination) {
<section class="relative overflow-hidden pt-6">
@heroGradient()
<div class="relative z-10">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 lg:py-16">
<div class="text-center">
<!-- Title -->
<div class="mt-5">
<h1 class="block font-extrabold text-4xl md:text-5xl lg:text-6xl text-neutral-200">
Monero Node #{ fmt.Sprintf("%d", data.ID) }
</h1>
</div>
<hr class="mt-6"/>
</div>
<div class="max-w-3xl mx-auto mt-8">
@Node(data)
</div>
</div>
</div>
</section>
<!-- End Hero -->
<div class="flex flex-col max-w-6xl mx-auto mb-10">
<div class="my-6 text-center">
<div class="mt-5">
<h2 class="block font-extrabold text-4xl md:text-4xl lg:text-5xl text-neutral-200">Probe Logs</h2>
</div>
</div>
<div class="min-w-full inline-block align-middle">
@TableLogs(fmt.Sprintf("/remote-nodes/id/%d", data.ID), logs, q, p)
</div>
</div>
}
templ TableLogs(hxPath string, data monero.FetchLogs, q monero.QueryLogs, p paging.Pagination) {
<div id="tbl_logs" class="bg-neutral-800 border border-neutral-700 rounded-xl shadow-sm overflow-hidden">
<div class="px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-b border-neutral-700">
@DtRowPerPage(hxPath, "#tbl_logs", q.Limit, q)
<div>
@DtRefreshInterval(hxPath, "#tbl_logs", q.Refresh, q)
</div>
@DtReload(hxPath, "#tbl_logs", q)
</div>
<div class="overflow-x-auto">
<table class="dt">
<thead>
<tr>
<th scope="col">#ID</th>
<th scope="col">Prober ID</th>
<th scope="col">Status</th>
<th scope="col">Height</th>
<th scope="col">Adjusted Time</th>
<th scope="col">DB Size</th>
<th scope="col">Difficulty</th>
@DtThSort(hxPath, "#tbl_logs", "Est. Fee", "estimate_fee", q.SortBy, q.SortDirection, q)
@DtThSort(hxPath, "#tbl_logs", "Check", "date_checked", q.SortBy, q.SortDirection, q)
@DtThSort(hxPath, "#tbl_logs", "Runtime", "fetch_runtime", q.SortBy, q.SortDirection, q)
</tr>
<tr>
<td colspan="3">
<select
id="status"
name="status"
class="frameless"
autocomplete="off"
hx-get={ fmt.Sprintf("%s?%s", hxPath, paging.EncodedQuery(q, []string{"status"})) }
hx-trigger="change"
hx-push-url="true"
hx-target="#tbl_logs"
hx-swap="outerHTML"
>
for _, status := range nodeStatuses {
<option value={ fmt.Sprintf("%d", status.Code) } selected?={ status.Code == q.Status }>{ status.Text }</option>
}
</select>
</td>
<td colspan="7">
<input
type="text"
id="failed_reason"
name="failed_reason"
value={ fmt.Sprintf("%s", q.FailedReason) }
autocomplete="off"
class="frameless"
placeholder="Filter reason"
hx-get={ fmt.Sprintf("%s?%s", hxPath, paging.EncodedQuery(q, []string{"failed_reason"})) }
hx-push-url="true"
hx-trigger="keyup changed delay:0.4s"
hx-target="#tbl_logs"
hx-swap="outerHTML"
/>
</td>
</tr>
</thead>
<tbody>
for _, row := range data.Items {
<tr>
<td>{ fmt.Sprintf("%d", row.ID) }</td>
<td>{ fmt.Sprintf("%d", row.ProberID) }</td>
if row.Status == 1 {
<td class="text-green-500">OK</td>
<td class="text-right">{ fmt.Sprintf("%d", row.Height) }</td>
<td>{ time.Unix(row.AdjustedTime, 0).UTC().Format("Jan 2, 2006 15:04 MST") }</td>
<td>{ utils.FormatBytes(row.DatabaseSize, 0) }</td>
<td>{ utils.FormatHashes(float64(row.Difficulty)) }</td>
<td class="text-right">{ fmt.Sprintf("%d", row.EstimateFee) }</td>
} else {
<td class="text-red-500">ERR</td>
<td colspan="5">{ row.FailedReason }</td>
}
<td title={ time.Unix(row.DateChecked, 0).UTC().Format("Jan 2, 2006 15:04 MST") }>{ utils.TimeSince(row.DateChecked) }</td>
<td class="text-right">{ utils.FormatFloat(row.FetchRuntime) }s</td>
</tr>
}
</tbody>
</table>
</div>
<div class="px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-t border-neutral-700">
@DtRowCount(p.CurrentPage, data.RowsPerPage, data.TotalRows)
@DtPagination(hxPath, "#tbl_logs", q, p)
</div>
</div>
}
templ cellHostPort(id, port uint, hostname, ips string, isTor, ipv6Only bool) {
if isTor {
<button
class="max-w-40 truncate text-orange-400"
class="max-w-40 truncate text-orange-400 hover:brightness-125"
hx-get={ fmt.Sprintf("/remote-nodes/id/%d", id) }
hx-push-url="false"
hx-target="#modal-section"
@ -323,12 +518,12 @@ templ cellStatuses(isAvailable bool, statuses [5]int) {
templ cellUptime(uptime float64) {
if uptime >= 98 {
<span class="text-green-500">{ formatFloat(uptime) }%</span>
<span class="text-green-500">{ utils.FormatFloat(uptime) }%</span>
} else if uptime < 98 && uptime >= 80 {
<span class="text-sky-500">{ formatFloat(uptime) }%</span>
<span class="text-sky-500">{ utils.FormatFloat(uptime) }%</span>
} else if uptime < 80 && uptime > 75 {
<span class="text-orange-500">{ formatFloat(uptime) }%</span>
<span class="text-orange-500">{ utils.FormatFloat(uptime) }%</span>
} else {
<span class="text-rose-500">{ formatFloat(uptime) }%</span>
<span class="text-rose-500">{ utils.FormatFloat(uptime) }%</span>
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,47 +0,0 @@
package views
import (
"fmt"
"strconv"
"time"
)
// Convert the float to a string, trimming unnecessary zeros
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
// TimeSince converts an int64 timestamp to a relative time string
func timeSince(timestamp int64) string {
var duration time.Duration
var suffix string
t := time.Unix(timestamp, 0)
if t.After(time.Now()) {
duration = time.Until(t)
suffix = "from now"
} else {
duration = time.Since(t)
suffix = "ago"
}
switch {
case duration < time.Minute:
return fmt.Sprintf("%ds %s", int(duration.Seconds()), suffix)
case duration < time.Hour:
return fmt.Sprintf("%dm %s", int(duration.Minutes()), suffix)
case duration < time.Hour*24:
return fmt.Sprintf("%dh %s", int(duration.Hours()), suffix)
case duration < time.Hour*24*7:
return fmt.Sprintf("%dd %s", int(duration.Hours()/24), suffix)
case duration < time.Hour*24*30:
return fmt.Sprintf("%dw %s", int(duration.Hours()/(24*7)), suffix)
default:
months := int(duration.Hours() / (24 * 30))
if months == 1 {
return fmt.Sprintf("1 month %s", suffix)
}
return fmt.Sprintf("%d months %s", months, suffix)
}
}

View file

@ -8,6 +8,7 @@ import (
"log/slog"
"math"
"net"
"regexp"
"slices"
"strings"
"time"
@ -221,8 +222,8 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
ipAddr = hostIp.String()
ips = ip.SliceToString(hostIps)
} else {
if strings.HasPrefix(hostname, "http://") || strings.HasPrefix(hostname, "https://") {
return errors.New("Don't start hostname with http:// or https://, just put your hostname")
if !validTorHostname(hostname) {
return errors.New("Invalid TOR v3 .onion hostname")
}
}
@ -295,6 +296,14 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
return nil
}
// validTorHostname shecks if a given hostname is a valid TOR v3 .onion address
// with optional subdomain
//
// TOR v3 .onion addresses are 56 characters of `base32` followed by ".onion"
func validTorHostname(hostname string) bool {
return regexp.MustCompile(`^([a-z0-9-]+\.)*[a-z2-7]{56}\.onion$`).MatchString(hostname)
}
func (r *moneroRepo) Delete(id uint) error {
if _, err := r.db.Exec(`DELETE FROM tbl_node WHERE id = ?`, id); err != nil {
return err

View file

@ -131,6 +131,52 @@ func Benchmark_QueryNodes_toSQL(b *testing.B) {
}
}
// Single test:
// go test -race ./internal/monero -run=TestValidTorHostname -v
func TestValidTorHostname(t *testing.T) {
tests := []struct {
name string
host string
wantValid bool
}{
{
name: "Empty host",
host: "",
wantValid: false,
},
{
name: "Valid tor host",
host: "cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion",
wantValid: true,
},
{
name: "Valid tor host with subdomain",
host: "just-test.cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion",
wantValid: true,
},
{
name: "Invalid host",
host: "test.com",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if r := validTorHostname(tt.host); r != tt.wantValid {
t.Errorf("ValidTorHostname() error = %v, wantValid %v", r, tt.wantValid)
}
})
}
}
// Single bench test:
// go test ./internal/monero -bench validTorHostname -benchmem -run=^$ -v
func Benchmark_validTorHostname(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = validTorHostname("cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion")
}
}
// equalArgs is helper function for testing.
//
// This returns true if two slices of interface{} are equal.

View file

@ -11,17 +11,14 @@ import (
"time"
"github.com/ditatompel/xmr-remote-nodes/internal/ip/geo"
"github.com/ditatompel/xmr-remote-nodes/internal/paging"
)
type QueryLogs struct {
NodeID int // 0 for all, >0 for specific node
Status int // -1 for all, 0 for failed, 1 for success
FailedReason string // empty for all, if not empty, will be used as search from failed_reaso
RowsPerPage int
Page int
SortBy string
SortDirection string
paging.Paging
NodeID int `url:"node_id,omitempty"` // 0 for all, >0 for specific node
Status int `url:"status"` // -1 for all, 0 for failed, 1 for success
FailedReason string `url:"failed_reason,omitempty"` // empty for all, non empty string will be used as search
}
func (q QueryLogs) toSQL() (args []interface{}, where, sortBy, sortDirection string) {
@ -62,17 +59,18 @@ type FetchLog struct {
ProberID int `db:"prober_id" json:"prober_id"`
Status int `db:"is_available" json:"status"`
Height int `db:"height" json:"height"`
AdjustedTime int `db:"adjusted_time" json:"adjusted_time"`
AdjustedTime int64 `db:"adjusted_time" json:"adjusted_time"`
DatabaseSize int `db:"database_size" json:"database_size"`
Difficulty int `db:"difficulty" json:"difficulty"`
EstimateFee int `db:"estimate_fee" json:"estimate_fee"`
DateChecked int `db:"date_checked" json:"date_checked"`
DateChecked int64 `db:"date_checked" json:"date_checked"`
FailedReason string `db:"failed_reason" json:"failed_reason"`
FetchRuntime float64 `db:"fetch_runtime" json:"fetch_runtime"`
}
type FetchLogs struct {
TotalRows int `json:"total_rows"`
TotalPages int `json:"total_pages"` // total pages
RowsPerPage int `json:"rows_per_page"`
Items []*FetchLog `json:"items"`
}
@ -82,15 +80,18 @@ func (r *moneroRepo) Logs(q QueryLogs) (FetchLogs, error) {
args, where, sortBy, sortDirection := q.toSQL()
var fetchLogs FetchLogs
fetchLogs.RowsPerPage = q.RowsPerPage
fetchLogs.RowsPerPage = q.Limit
qTotal := fmt.Sprintf(`SELECT COUNT(id) FROM tbl_probe_log %s`, where)
err := r.db.QueryRow(qTotal, args...).Scan(&fetchLogs.TotalRows)
if err != nil {
return fetchLogs, err
}
args = append(args, q.RowsPerPage, (q.Page-1)*q.RowsPerPage)
fetchLogs.TotalPages = int(math.Ceil(float64(fetchLogs.TotalRows) / float64(q.Limit)))
args = append(args, q.Limit, (q.Page-1)*q.Limit)
fmt.Printf("%+v", fetchLogs)
query := fmt.Sprintf(`
SELECT
*

View file

@ -3,6 +3,7 @@ package monero
import (
"testing"
"github.com/ditatompel/xmr-remote-nodes/internal/paging"
"github.com/jmoiron/sqlx/types"
)
@ -64,13 +65,15 @@ func TestQueryLogs_toSQL(t *testing.T) {
{
name: "Default query",
fields: QueryLogs{
NodeID: 0,
Status: -1,
FailedReason: "",
RowsPerPage: 10,
Page: 1,
SortBy: "date_checked",
SortDirection: "desc",
Paging: paging.Paging{
Limit: 10,
Page: 1,
SortBy: "date_checked",
SortDirection: "desc",
},
NodeID: 0,
Status: -1,
FailedReason: "",
},
wantArgs: []interface{}{},
wantWhere: "",
@ -80,13 +83,15 @@ func TestQueryLogs_toSQL(t *testing.T) {
{
name: "With node_id query",
fields: QueryLogs{
NodeID: 1,
Status: -1,
FailedReason: "",
RowsPerPage: 10,
Page: 1,
SortBy: "date_checked",
SortDirection: "desc",
Paging: paging.Paging{
Limit: 10,
Page: 1,
SortBy: "date_checked",
SortDirection: "desc",
},
NodeID: 1,
Status: -1,
FailedReason: "",
},
wantArgs: []interface{}{1},
wantWhere: "WHERE node_id = ?",
@ -96,13 +101,15 @@ func TestQueryLogs_toSQL(t *testing.T) {
{
name: "All possible query",
fields: QueryLogs{
NodeID: 1,
Status: 0,
FailedReason: "test",
RowsPerPage: 10,
Page: 1,
SortBy: "date_checked",
SortDirection: "asc",
Paging: paging.Paging{
Limit: 10,
Page: 1,
SortBy: "date_checked",
SortDirection: "asc",
},
NodeID: 1,
Status: 0,
FailedReason: "test",
},
wantArgs: []interface{}{1, 0, "%test%"},
wantWhere: "WHERE node_id = ? AND is_available = ? AND failed_reason LIKE ?",
@ -113,13 +120,15 @@ func TestQueryLogs_toSQL(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q := QueryLogs{
NodeID: tt.fields.NodeID,
Status: tt.fields.Status,
FailedReason: tt.fields.FailedReason,
RowsPerPage: tt.fields.RowsPerPage,
Page: tt.fields.Page,
SortBy: tt.fields.SortBy,
SortDirection: tt.fields.SortDirection,
Paging: paging.Paging{
Limit: tt.fields.Limit,
Page: tt.fields.Page,
SortBy: tt.fields.SortBy,
SortDirection: tt.fields.SortDirection,
},
NodeID: tt.fields.NodeID,
Status: tt.fields.Status,
FailedReason: tt.fields.FailedReason,
}
gotArgs, gotWhere, gotSortBy, gotSortDirection := q.toSQL()
if !equalArgs(gotArgs, tt.wantArgs) {

108
utils/human_readable.go Normal file
View file

@ -0,0 +1,108 @@
package utils
import (
"fmt"
"math"
"strconv"
"time"
)
// TimeSince converts an int64 timestamp to a relative time string
func TimeSince(timestamp int64) string {
var duration time.Duration
var suffix string
t := time.Unix(timestamp, 0)
if t.After(time.Now()) {
duration = time.Until(t)
suffix = "from now"
} else {
duration = time.Since(t)
suffix = "ago"
}
switch {
case duration < time.Minute:
return fmt.Sprintf("%ds %s", int(duration.Seconds()), suffix)
case duration < time.Hour:
return fmt.Sprintf("%dm %s", int(duration.Minutes()), suffix)
case duration < time.Hour*24:
return fmt.Sprintf("%dh %s", int(duration.Hours()), suffix)
case duration < time.Hour*24*7:
return fmt.Sprintf("%dd %s", int(duration.Hours()/24), suffix)
case duration < time.Hour*24*30:
return fmt.Sprintf("%dw %s", int(duration.Hours()/(24*7)), suffix)
default:
months := int(duration.Hours() / (24 * 30))
if months == 1 {
return fmt.Sprintf("1 month %s", suffix)
}
return fmt.Sprintf("%d months %s", months, suffix)
}
}
// Convert the float to a string, trimming unnecessary zeros
func FormatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
// Formats bytes as a human-readable string with the specified number of decimal places.
func FormatBytes(bytes, decimals int) string {
if bytes == 0 {
return "0 Bytes"
}
const k float64 = 1024
sizes := []string{"Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
i := int(math.Floor(math.Log(float64(bytes)) / math.Log(k)))
dm := decimals
if dm < 0 {
dm = 0
}
value := float64(bytes) / math.Pow(k, float64(i))
return fmt.Sprintf("%.*f %s", dm, value, sizes[i])
}
// Formats a hash value (h) into human readable format.
//
// This function was adapted from jtgrassie/monero-pool project.
// Source: https://github.com/jtgrassie/monero-pool/blob/master/src/webui-embed.html
//
// Copyright (c) 2018, The Monero Project
func FormatHashes(h float64) string {
switch {
case h < 1e-12:
return "0 H"
case h < 1e-9:
return fmt.Sprintf("%.0f pH", maxPrecision(h*1e12, 0))
case h < 1e-6:
return fmt.Sprintf("%.0f nH", maxPrecision(h*1e9, 0))
case h < 1e-3:
return fmt.Sprintf("%.0f μH", maxPrecision(h*1e6, 0))
case h < 1:
return fmt.Sprintf("%.0f mH", maxPrecision(h*1e3, 0))
case h < 1e3:
return fmt.Sprintf("%.0f H", h)
case h < 1e6:
return fmt.Sprintf("%.2f KH", maxPrecision(h*1e-3, 2))
case h < 1e9:
return fmt.Sprintf("%.2f MH", maxPrecision(h*1e-6, 2))
default:
return fmt.Sprintf("%.2f GH", maxPrecision(h*1e-9, 2))
}
}
// Returns a number with a maximum precision.
//
// This function was adapted from jtgrassie/monero-pool project.
// Source: https://github.com/jtgrassie/monero-pool/blob/master/src/webui-embed.html
//
// Copyright (c) 2018, The Monero Project
func maxPrecision(n float64, p int) float64 {
format := "%." + strconv.Itoa(p) + "f"
result, _ := strconv.ParseFloat(fmt.Sprintf(format, n), 64)
return result
}