feat!: split ui and api backend

This commit is contained in:
Guanran Wang 2024-10-25 20:55:03 +08:00
parent 50ff583672
commit b88b96546f
Signed by: nyancat
GPG key ID: 91F97D9ED12639CF
11 changed files with 243 additions and 250 deletions

View file

View file

173
api/main.go Normal file
View file

@ -0,0 +1,173 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"github.com/oschwald/geoip2-golang"
)
var (
asnDB *geoip2.Reader
cityDB *geoip2.Reader
config serverConfig
cache sync.Map
)
const (
devMode = "dev"
prodMode = "prod"
)
type serverConfig struct {
ASNDB string
CityDB string
Listen string
Mode string
}
type body struct {
IP net.IP `json:"ip"`
Country string `json:"country"`
Region string `json:"region"`
City string `json:"city"`
ASN uint `json:"asn"`
Organization string `json:"organization"`
}
type bodyError struct {
Error string `json:"error"`
}
func main() {
config = serverConfig{
ASNDB: os.Getenv("IP_CHECKER_ASN_DB"),
CityDB: os.Getenv("IP_CHECKER_CITY_DB"),
Listen: getEnvOr("IP_CHECKER_LISTEN", ":8080"),
Mode: os.Getenv("IP_CHECKER_MODE"),
}
if config.CityDB != "" {
var err error
cityDB, err = geoip2.Open(config.CityDB)
if err != nil {
log.Fatalf("Error opening GeoIP City database: %v", err)
}
defer cityDB.Close()
} else {
log.Fatalln("'IP_CHECKER_CITY_DB' not set")
}
if config.ASNDB != "" {
var err error
asnDB, err = geoip2.Open(config.ASNDB)
if err != nil {
log.Fatalf("Error opening GeoIP ASN database: %v", err)
}
defer asnDB.Close()
} else {
log.Fatalln("'IP_CHECKER_ASN_DB' not set")
}
http.HandleFunc("/api/v1/ip", handleRequest)
log.Printf("Starting server on %s", config.Listen)
log.Fatal(http.ListenAndServe(config.Listen, nil))
}
func generateJSON(ip string) (body, error) {
var data body
parsedIP := net.ParseIP(ip)
// check for reserved ip
if parsedIP == nil {
return body{}, errors.New("invalid IP")
} else if parsedIP.IsPrivate() {
return body{}, errors.New("private IP")
} else if parsedIP.IsLoopback() {
return body{}, errors.New("loopback IP")
} else if parsedIP.IsUnspecified() {
return body{}, errors.New("unspecified IP")
}
// cache
if cached, found := cache.Load(ip); found && config.Mode == prodMode {
return cached.(body), nil
}
// query
cityRecord, err := cityDB.City(parsedIP)
if err != nil {
log.Printf("GeoIP City lookup failed for IP: %s, error: %v", ip, err)
return body{}, errors.New("City record lookup failed")
}
ASNRecord, err := asnDB.ASN(parsedIP)
if err != nil {
log.Printf("GeoIP ASN lookup failed for IP: %s, error: %v", ip, err)
return body{}, errors.New("ASN record lookup failed")
}
data.IP = parsedIP
data.Country = cityRecord.Country.Names["en"]
data.Region = cityRecord.Subdivisions[0].Names["en"]
data.City = cityRecord.City.Names["en"]
data.ASN = ASNRecord.AutonomousSystemNumber
data.Organization = ASNRecord.AutonomousSystemOrganization
if config.Mode == prodMode {
cache.Store(ip, data)
}
return data, nil
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
sourceIP := func() string {
if q := r.URL.Query().Get("q"); q != "" {
return q
}
if config.Mode == "dev" {
return "223.5.5.5"
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
errorText := fmt.Sprintf("Unable to parse remote address: %v", err)
log.Printf(errorText)
return errorText
}
return host
}()
bodyData, err := generateJSON(sourceIP)
if err != nil {
errorBody := bodyError{
Error: err.Error(),
}
errorData, _ := json.MarshalIndent(errorBody, "", " ")
http.Error(w, string(errorData)+"\n", http.StatusInternalServerError)
return
}
bodyJson, _ := json.MarshalIndent(bodyData, "", " ")
fmt.Fprintf(w, "%s\n", bodyJson)
}
func getEnvOr(key string, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}

View file

@ -1,50 +0,0 @@
async function main() {
try {
const [geoIP, speedTest, ipApiIs] = await Promise.all([
fetchAsync("https://api.ip.sb/geoip"),
fetchAsync("https://api-v3.speedtest.cn/ip"),
fetchAsync("https://api.ipapi.is/"),
]);
// Update DOM for IP.sb
updateDom("check-ipsb-ip", geoIP.ip);
updateDom("check-ipsb-location", [geoIP.city, geoIP.country, geoIP.isp]);
// Update DOM for SpeedTest.cn
const { ip, city, country, isp } = speedTest.data;
updateDom("check-speedtestcn-ip", ip);
updateDom("check-speedtestcn-location", [city, country, isp]);
// Update DOM for IPAPI.is
const {
ip: ipapiIP,
location: { city: ipapiCity, country: ipapiCountry },
company: { name: companyName },
} = ipApiIs;
updateDom("check-ipapiis-ip", ipapiIP);
updateDom("check-ipapiis-location", [ipapiCity, ipapiCountry, companyName]);
} catch (error) {
console.log("Error fetching data:", error);
}
}
async function fetchAsync(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.log("Fetch error:", error);
throw error;
}
}
function updateDom(elementId, content) {
document.getElementById(elementId).innerHTML = Array.isArray(content)
? content.join(", ")
: content;
}
window.onload = main;

195
main.go
View file

@ -1,195 +0,0 @@
package main
import (
"fmt"
"html/template"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"github.com/oschwald/geoip2-golang"
)
var (
ASNDB *geoip2.Reader
CityDB *geoip2.Reader
tmpl *template.Template
config ServerConfig
ipCache sync.Map
)
type ServerConfig struct {
ASNDB string
CityDB string
Listen string
Mode string
}
func main() {
config = ServerConfig{
ASNDB: os.Getenv("IP_CHECKER_ASN_DB"),
CityDB: os.Getenv("IP_CHECKER_CITY_DB"),
Listen: getEnvOr("IP_CHECKER_LISTEN", ":8080"),
Mode: os.Getenv("IP_CHECKER_MODE"),
}
if config.CityDB != "" {
var err error
CityDB, err = geoip2.Open(config.CityDB)
if err != nil {
log.Fatalf("Error opening GeoIP City database: %v", err)
}
defer CityDB.Close()
}
if config.ASNDB != "" {
var err error
ASNDB, err = geoip2.Open(config.ASNDB)
if err != nil {
log.Fatalf("Error opening GeoIP ASN database: %v", err)
}
defer ASNDB.Close()
}
tmpl = template.Must(template.ParseFiles("assets/index.html"))
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./assets/static"))))
http.HandleFunc("/{$}", handleRequest)
http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `User-Agent: *
Disallow: /harming/humans
Disallow: /ignoring/human/orders
Disallow: /harm/to/self
`)
})
log.Printf("Starting server on %s", config.Listen)
log.Fatal(http.ListenAndServe(config.Listen, nil))
}
func getIPCountry(ip string) string {
parsedIP := net.ParseIP(ip)
// check for reserved ip
if parsedIP.IsPrivate() {
return "private IP"
}
if parsedIP.IsLoopback() {
return "loopback IP"
}
if parsedIP == nil {
log.Printf("Invalid IP address: %s", ip)
return "invalid IP"
}
// check if DB exists
// FIXME:
if config.CityDB == "" && config.ASNDB == "" {
log.Printf("GeoIP database not set. Returning 'unknown'")
return "unknown"
}
// cache
if cached, found := ipCache.Load(ip); found {
return cached.(string)
}
// parse DB
cityRecord, err := CityDB.City(parsedIP)
if err != nil {
log.Printf("GeoIP City lookup failed for IP: %s, error: %v", ip, err)
return "City record lookup failed"
}
ASNRecord, err := ASNDB.ASN(parsedIP)
if err != nil {
log.Printf("GeoIP ASN lookup failed for IP: %s, error: %v", ip, err)
return "ASN record lookup failed"
}
// parse text
country, ok := cityRecord.Country.Names["en"]
if !ok {
log.Printf("Country name not found in GeoIP data for IP: %s", ip)
return "unknown"
}
region, ok := cityRecord.Subdivisions[0].Names["en"]
if !ok {
log.Printf("Region name not found in GeoIP data for IP: %s", ip)
return "unknown"
}
ASN := ASNRecord.AutonomousSystemOrganization
if ASN == "" {
log.Printf("ASN name not found in GeoIP data for IP: %s", ip)
return "unknown"
}
result := region + ", " + country + ", " + ASN
ipCache.Store(ip, result)
return result
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
sourceIP := func() string {
if config.Mode == "dev" {
return "223.5.5.5"
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
errorText := fmt.Sprintf("Unable to parse remote address: %v", err)
log.Printf(errorText)
return errorText
}
return host
}()
isHeadless := strings.HasPrefix(r.Header.Get("User-Agent"), "curl/")
log.Printf("r.URL.Path: %s, sourceIP: %s, isHeadless: %t, User-Agent: %s", r.URL.Path, sourceIP, isHeadless, r.Header.Get("User-Agent"))
if isHeadless {
fmt.Fprintf(w, "%s\n", sourceIP)
return
} else {
csp := []string{
"default-src 'none'",
"img-src 'self'",
"script-src-elem 'self'",
"style-src-elem 'self' fonts.googleapis.com",
"font-src fonts.gstatic.com",
"connect-src api.ip.sb api-v3.speedtest.cn api.ipapi.is",
}
w.Header().Set("Content-Security-Policy", strings.Join(csp, "; "))
data := map[string]string{
"sourceIP": sourceIP,
"sourceCountry": getIPCountry(sourceIP),
}
err := tmpl.Execute(w, data)
if err != nil {
http.Error(w, "Unable to load template", http.StatusInternalServerError)
log.Printf("Template execution error: %v", err)
return
}
}
}
func getEnvOr(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}

View file

@ -4,7 +4,7 @@ buildGoModule {
pname = "ip-checker"; pname = "ip-checker";
version = "dev"; version = "dev";
src = ./.; src = ./api;
vendorHash = "sha256-VvZcnTEgPXlAYEf2+2WZ2xlU4TWhTz+dBiA4j76GSvM="; vendorHash = "sha256-VvZcnTEgPXlAYEf2+2WZ2xlU4TWhTz+dBiA4j76GSvM=";

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Ny4 IP Checker" /> <meta name="description" content="Ny4 IP Checker" />
<title>What is my IP?</title> <title>What is my IP?</title>
<link href="static/generated.css" rel="stylesheet" /> <link href="generated.css" rel="stylesheet" />
<script src="static/script.js" defer></script> <script src="script.js" defer></script>
</head> </head>
<body <body
class="text-violet-950 bg-violet-100 dark:text-slate-100 dark:bg-slate-900 font-mono font-medium text-sm md:text-base" class="text-violet-950 bg-violet-100 dark:text-slate-100 dark:bg-slate-900 font-mono font-medium text-sm md:text-base"
@ -26,8 +26,8 @@
<tbody> <tbody>
<tr> <tr>
<td class="table-cell">local</td> <td class="table-cell">local</td>
<td class="table-cell">{{.sourceIP}}</td> <td class="table-cell" id="check-ny4-ip">Loading...</td>
<td class="table-cell">{{.sourceCountry}}</td> <td class="table-cell" id="check-ny4-location">Loading...</td>
</tr> </tr>
<tr> <tr>
<td class="table-cell">ip.sb</td> <td class="table-cell">ip.sb</td>

65
ui/script.js Normal file
View file

@ -0,0 +1,65 @@
async function main() {
try {
const [ny4, ipsb, speedtestcn, ipapiis] = await Promise.all([
fetchAsync("https://ip.ny4.dev/api/v1/ip"),
fetchAsync("https://api.ip.sb/geoip"),
fetchAsync("https://api-v3.speedtest.cn/ip"),
fetchAsync("https://api.ipapi.is/"),
]);
updateDom("check-ny4-ip", ny4.ip);
updateDom("check-ny4-location", [
ny4.city,
ny4.region,
ny4.country,
ny4.organization,
]);
updateDom("check-ipsb-ip", ipsb.ip);
updateDom("check-ipsb-location", [
ipsb.city,
ipsb.region,
ipsb.country,
ipsb.isp,
]);
updateDom("check-speedtestcn-ip", speedtestcn.data.ip);
updateDom("check-speedtestcn-location", [
speedtestcn.data.city,
speedtestcn.data.province,
speedtestcn.data.country,
speedtestcn.data.isp,
]);
updateDom("check-ipapiis-ip", ipapiis.ip);
updateDom("check-ipapiis-location", [
ipapiis.location.city,
ipapiis.location.state,
ipapiis.location.country,
ipapiis.company.name,
]);
} catch (error) {
console.log("Error fetching data:", error);
}
}
async function fetchAsync(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.log("Fetch error:", error);
throw error;
}
}
function updateDom(elementId, content) {
document.getElementById(elementId).innerHTML = Array.isArray(content)
? content.join(", ")
: content;
}
window.onload = main;