first commit
This commit is contained in:
600
internal/httpapi/page.go
Normal file
600
internal/httpapi/page.go
Normal file
@@ -0,0 +1,600 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var homeTemplate = template.Must(template.New("home").Parse(homePageHTML))
|
||||
|
||||
type homePageData struct {
|
||||
Info ClientInfo
|
||||
BaseURL string
|
||||
Commands []commandLink
|
||||
Endpoints []endpointLink
|
||||
}
|
||||
|
||||
type commandLink struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
type endpointLink struct {
|
||||
Path string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, req *http.Request) {
|
||||
baseURL := s.requestBaseURL(req)
|
||||
data := homePageData{
|
||||
Info: s.clientInfo(req),
|
||||
BaseURL: baseURL,
|
||||
Commands: []commandLink{
|
||||
{Label: "IP", Value: "curl " + baseURL + "/ip"},
|
||||
{Label: "JSON", Value: "curl " + baseURL + "/json"},
|
||||
{Label: "Headers", Value: "curl " + baseURL + "/headers"},
|
||||
},
|
||||
Endpoints: []endpointLink{
|
||||
{Path: "/", Description: "Plain IP for CLI, dashboard for browsers"},
|
||||
{Path: "/ip", Description: "Plain text client IP"},
|
||||
{Path: "/json", Description: "Request metadata as JSON"},
|
||||
{Path: "/headers", Description: "Request headers as text"},
|
||||
{Path: "/user-agent", Description: "User agent as text"},
|
||||
{Path: "/healthz", Description: "Health check"},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = homeTemplate.Execute(w, data)
|
||||
}
|
||||
|
||||
func (s *Server) requestBaseURL(req *http.Request) string {
|
||||
scheme := "http"
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
host := req.Host
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
|
||||
if s.resolver.TrustsProxyHeaders(req) {
|
||||
if proto := firstCommaSeparatedHeader(req.Header.Get("X-Forwarded-Proto")); proto == "http" || proto == "https" {
|
||||
scheme = proto
|
||||
}
|
||||
|
||||
if forwardedHost := firstCommaSeparatedHeader(req.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
}
|
||||
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
func firstCommaSeparatedHeader(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, _, _ = strings.Cut(value, ",")
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
const homePageHTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>IP Echo</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #eef4f8;
|
||||
--text: #162033;
|
||||
--muted: #667085;
|
||||
--line: #d9e2ec;
|
||||
--blue: #1167b1;
|
||||
--cyan: #0b9fb3;
|
||||
--green: #1f8a5b;
|
||||
--orange: #c85f16;
|
||||
--shadow: 0 18px 45px rgba(22, 32, 51, 0.11);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff 0, var(--bg) 340px),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1120px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 40px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
color: var(--green);
|
||||
background: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ip-panel,
|
||||
.trace-panel,
|
||||
.section,
|
||||
.endpoint {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.ip-panel {
|
||||
padding: 30px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--orange);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ip-value {
|
||||
margin: 12px 0 20px;
|
||||
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 42px;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 17px;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--blue);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
border-color: var(--line);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.detail span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail strong {
|
||||
display: block;
|
||||
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.trace-panel {
|
||||
min-height: 100%;
|
||||
padding: 22px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(17, 103, 177, 0.08), transparent 55%),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.trace-visual {
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.trace-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.trace-copy {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 17px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.commands {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.command {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.command span {
|
||||
color: var(--cyan);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.endpoints {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
min-height: 92px;
|
||||
padding: 14px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.endpoint code {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--blue);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.endpoint span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
background: #ffffff;
|
||||
color: var(--green);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.toast.is-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero,
|
||||
.grid,
|
||||
.details,
|
||||
.endpoints {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ip-value {
|
||||
font-size: 31px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.shell {
|
||||
width: min(100% - 20px, 1120px);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ip-panel,
|
||||
.trace-panel,
|
||||
.section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ip-value {
|
||||
font-size: 27px;
|
||||
}
|
||||
|
||||
.command {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<svg class="mark" viewBox="0 0 48 48" role="img" aria-label="IP Echo">
|
||||
<rect width="48" height="48" rx="8" fill="#1167b1"></rect>
|
||||
<path d="M14 25h20M24 14v20" stroke="#ffffff" stroke-width="3" stroke-linecap="round"></path>
|
||||
<circle cx="14" cy="25" r="4" fill="#f59f45"></circle>
|
||||
<circle cx="34" cy="25" r="4" fill="#41c7d7"></circle>
|
||||
<circle cx="24" cy="14" r="4" fill="#8bd6aa"></circle>
|
||||
<circle cx="24" cy="34" r="4" fill="#ffffff"></circle>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>IP Echo</strong>
|
||||
<span>Client network lookup</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status">Online</div>
|
||||
</header>
|
||||
|
||||
<section class="hero" aria-label="Client IP summary">
|
||||
<div class="ip-panel">
|
||||
<p class="eyebrow">Public address</p>
|
||||
<h1>Your visible IP address</h1>
|
||||
<div class="ip-value" id="client-ip">{{.Info.IP}}</div>
|
||||
<div class="actions">
|
||||
<button class="button button-primary" type="button" data-copy="{{.Info.IP}}">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 8h10v12H8zM6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
Copy IP
|
||||
</button>
|
||||
<a class="button button-secondary" href="/json">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 17 3 12l5-5M16 7l5 5-5 5M14 4l-4 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
JSON
|
||||
</a>
|
||||
<a class="button button-secondary" href="/headers">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M4 12h16M4 17h10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
Headers
|
||||
</a>
|
||||
</div>
|
||||
<div class="details">
|
||||
<div class="detail">
|
||||
<span>Remote address</span>
|
||||
<strong>{{.Info.RemoteAddr}}</strong>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span>Method</span>
|
||||
<strong>{{.Info.Method}}</strong>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span>User agent</span>
|
||||
<strong>{{.Info.UserAgent}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="trace-panel" aria-label="Request path">
|
||||
<svg class="trace-visual" viewBox="0 0 360 220" role="img" aria-label="Browser request reaching IP Echo">
|
||||
<defs>
|
||||
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop stop-color="#1167b1"></stop>
|
||||
<stop offset="0.55" stop-color="#0b9fb3"></stop>
|
||||
<stop offset="1" stop-color="#1f8a5b"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="1" y="1" width="358" height="218" rx="8" fill="#f8fbfd" stroke="#d9e2ec"></rect>
|
||||
<path d="M72 112 C126 50, 214 50, 286 108" fill="none" stroke="url(#line)" stroke-width="5" stroke-linecap="round"></path>
|
||||
<path d="M72 112 C126 170, 214 170, 286 108" fill="none" stroke="#d9e2ec" stroke-width="2" stroke-dasharray="7 8" stroke-linecap="round"></path>
|
||||
<circle cx="72" cy="112" r="28" fill="#ffffff" stroke="#1167b1" stroke-width="4"></circle>
|
||||
<circle cx="286" cy="108" r="34" fill="#ffffff" stroke="#1f8a5b" stroke-width="4"></circle>
|
||||
<circle cx="179" cy="58" r="12" fill="#f59f45"></circle>
|
||||
<circle cx="179" cy="164" r="12" fill="#41c7d7"></circle>
|
||||
<path d="M58 104h28v18H58zM62 99h20" fill="none" stroke="#1167b1" stroke-width="3" stroke-linejoin="round"></path>
|
||||
<path d="M271 96h30v24h-30zM277 102h18M277 109h18M277 116h10" fill="none" stroke="#1f8a5b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<text x="72" y="158" text-anchor="middle" font-family="Consolas, monospace" font-size="13" fill="#667085">client</text>
|
||||
<text x="286" y="158" text-anchor="middle" font-family="Consolas, monospace" font-size="13" fill="#667085">server</text>
|
||||
</svg>
|
||||
<p class="trace-title">Request received</p>
|
||||
<p class="trace-copy">The service reports the address seen by this Go server. Behind a trusted proxy, it can use forwarded headers.</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="section">
|
||||
<h2>CLI</h2>
|
||||
<div class="commands">
|
||||
{{range .Commands}}
|
||||
<div class="command">
|
||||
<span>{{.Label}}</span>
|
||||
<code>{{.Value}}</code>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Endpoints</h2>
|
||||
<div class="endpoints">
|
||||
{{range .Endpoints}}
|
||||
<a class="endpoint" href="{{.Path}}">
|
||||
<code>{{.Path}}</code>
|
||||
<span>{{.Description}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite">Copied</div>
|
||||
|
||||
<script>
|
||||
const toast = document.querySelector("#toast");
|
||||
document.querySelectorAll("[data-copy]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(button.dataset.copy);
|
||||
toast.classList.add("is-visible");
|
||||
window.setTimeout(() => toast.classList.remove("is-visible"), 1400);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
Reference in New Issue
Block a user