Первый выпуск
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.mmdb
|
||||||
|
.env
|
||||||
|
errors
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# FROM golang:alpine3.16 AS build
|
||||||
|
FROM golang:trixie AS build
|
||||||
|
RUN apt install gcc g++ make git
|
||||||
|
WORKDIR /go/src/app
|
||||||
|
COPY . .
|
||||||
|
# RUN go get net/netip
|
||||||
|
RUN go get ./...
|
||||||
|
|
||||||
|
RUN GOOS=linux go build -ldflags="-s -w" -o ./bin/whois-geoip-web ./main.go
|
||||||
|
|
||||||
|
# FROM alpine:3.16
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt update -y && apt install -y tzdata ca-certificates
|
||||||
|
|
||||||
|
RUN mkdir -p /usr/local/share/ca-certificates/ && mkdir -p /usr/local/share/geoip/db/ && mkdir -p /usr/local/share/geoip/templates/
|
||||||
|
COPY samson.crt /usr/local/share/ca-certificates/root-ca.crt
|
||||||
|
|
||||||
|
RUN update-ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /usr/bin
|
||||||
|
|
||||||
|
COPY --from=build /go/src/app/bin /go/bin
|
||||||
|
COPY templates/*.html /usr/local/share/geoip/templates
|
||||||
|
|
||||||
|
CMD ["/go/bin/whois-geoip-web"]
|
||||||
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# whois-geoip-web
|
||||||
|
|
||||||
|
Вэб-сервис для поиска и вывода информации по IP-адресу.
|
||||||
|
|
||||||
|
Используются локальная БД MaxMind и whois-сервера.
|
||||||
|
|
||||||
|
База данных скачивается при старте с указанной ссылки и потом каждый день обновляется. При этом сравнивается контрольная сумма локальных и удаленных файлов и при совпадении обновления не происходит.
|
||||||
|
|
||||||
|
|
||||||
|
Лицензия GPL V3
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
Для получения JSON-данных через API запрос следует делать в виде http://whois.some.domain/api/x.x.x.x
|
||||||
|
|
||||||
|
Для передачи адреса ввиде ссылки на вэб-страницу - http://whois.some.domain/x.x.x.x
|
||||||
|
|
||||||
|
## Настройки
|
||||||
|
|
||||||
|
Настройки передаются через переменные окружения:
|
||||||
|
|
||||||
|
- MMDB_URL - адрес ресурса с базами данных (на каталог где лежат файлы)
|
||||||
|
- MMDB_LOCAL_PATH - локальный каталог с файлами (/usr/local/share/geoip/db)
|
||||||
|
- HTML_TEMPLATE_PATH - каталог с html-шаблонами вэб-страницы (/usr/local/share/geoip/templates)
|
||||||
|
- LISTEN_PORT - порт на котором будет запущен сервис (LISTEN_PORT:-8080)
|
||||||
|
|
||||||
|
## Внешний вид
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
whois-geoip-web:
|
||||||
|
image: $IMAGE_PATH/whois-geoip-web:$RELEASE_VERSION
|
||||||
|
container_name: whois-geoip-web
|
||||||
|
environment:
|
||||||
|
- MMDB_URL=${MMDB_URL:-localhost}
|
||||||
|
- MMDB_LOCAL_PATH=${MMDB_LOCAL_PATH:-/usr/local/share/geoip/db}
|
||||||
|
- HTML_TEMPLATE_PATH=${HTML_TEMPLATE_PATH:-/usr/local/share/geoip/templates}
|
||||||
|
- LISTEN_PORT=${LISTEN_PORT:-8181}
|
||||||
|
- TZ=Europe/Moscow
|
||||||
|
restart: always
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
logging:
|
||||||
|
# driver: "syslog"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
22
go.mod
Normal file
22
go.mod
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
module geoip
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.10
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/likexian/whois v1.15.6
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0
|
||||||
|
golang.org/x/text v0.31.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-co-op/gocron v1.37.0 // indirect
|
||||||
|
github.com/google/uuid v1.4.0 // indirect
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
)
|
||||||
55
go.sum
Normal file
55
go.sum
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
|
||||||
|
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
|
||||||
|
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||||
|
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/likexian/gokit v0.25.15 h1:QjospM1eXhdMMHwZRpMKKAHY/Wig9wgcREmLtf9NslY=
|
||||||
|
github.com/likexian/gokit v0.25.15/go.mod h1:S2QisdsxLEHWeD/XI0QMVeggp+jbxYqUxMvSBil7MRg=
|
||||||
|
github.com/likexian/whois v1.15.6 h1:hizngFHJTNQDlhwhU+FEGyPGxy8bRnf25gHDNrSB4Ag=
|
||||||
|
github.com/likexian/whois v1.15.6/go.mod h1:vx3kt3sZ4mx4XFgpaNp3GXQCZQIzAoyrUAkRtJwoM2I=
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
777
main.go
Normal file
777
main.go
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
//-----------------------------
|
||||||
|
//Distributed under GPL
|
||||||
|
//Author Sergey Kalinin
|
||||||
|
//svk@nuk-svk.ru
|
||||||
|
//------------------------------
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/likexian/whois"
|
||||||
|
"github.com/oschwald/geoip2-golang"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeoData struct {
|
||||||
|
// IP адрес
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
// Местоположение
|
||||||
|
Location struct {
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
Continent string `json:"continent,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
RegionISO string `json:"region_iso,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude float64 `json:"longitude,omitempty"`
|
||||||
|
TimeZone string `json:"time_zone,omitempty"`
|
||||||
|
PostalCode string `json:"postal_code,omitempty"`
|
||||||
|
} `json:"location"`
|
||||||
|
// Данные о сети
|
||||||
|
Network struct {
|
||||||
|
ASN uint `json:"asn,omitempty"`
|
||||||
|
ASOrg string `json:"autonomous_system_organization,omitempty"`
|
||||||
|
} `json:"network"`
|
||||||
|
// ISO коды
|
||||||
|
Codes struct {
|
||||||
|
CountryISO string `json:"country,omitempty"`
|
||||||
|
ContinentISO string `json:"continent,omitempty"`
|
||||||
|
} `json:"codes"`
|
||||||
|
// Прокси
|
||||||
|
Security struct {
|
||||||
|
IsAnonymousProxy bool `json:"is_anonymous_proxy,omitempty"`
|
||||||
|
IsSatelliteProvider bool `json:"is_satellite_provider,omitempty"`
|
||||||
|
} `json:"security"`
|
||||||
|
// Локаль
|
||||||
|
Metadata struct {
|
||||||
|
LocaleUsed string `json:"locale_used,omitempty"`
|
||||||
|
DataSource string `json:"data_source,omitempty"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateData структура для передачи данных в шаблон
|
||||||
|
type TemplateData struct {
|
||||||
|
*GeoData
|
||||||
|
Error string
|
||||||
|
JSONData string
|
||||||
|
WhoIsData string
|
||||||
|
BaseURL string
|
||||||
|
DBVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
MMDBURL string
|
||||||
|
MMDBLocalPath string
|
||||||
|
HTMLTemplatePath string
|
||||||
|
ListenPort string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cityDB *geoip2.Reader
|
||||||
|
countryDB *geoip2.Reader
|
||||||
|
asnDB *geoip2.Reader
|
||||||
|
templates *template.Template
|
||||||
|
cfg *Config
|
||||||
|
dbMutex sync.RWMutex
|
||||||
|
dbFilesVersion string
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var err error
|
||||||
|
// Загрузка конфигурации
|
||||||
|
cfg = LoadConfig()
|
||||||
|
|
||||||
|
// Запускаем скачивание файлов
|
||||||
|
mmdbDownload("GeoLite2-City.mmdb")
|
||||||
|
mmdbDownload("GeoLite2-Country.mmdb")
|
||||||
|
mmdbDownload("GeoLite2-ASN.mmdb")
|
||||||
|
|
||||||
|
// Запускаем планировщик параллельно с основным процессом
|
||||||
|
go sheduler()
|
||||||
|
|
||||||
|
// Открываем БД
|
||||||
|
pathGeoLite2City := filepath.Join(cfg.MMDBLocalPath, "GeoLite2-City.mmdb")
|
||||||
|
log.Println(pathGeoLite2City)
|
||||||
|
|
||||||
|
cityDB, err = geoip2.Open(pathGeoLite2City)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to open City database:", err)
|
||||||
|
}
|
||||||
|
defer cityDB.Close()
|
||||||
|
|
||||||
|
pathGeoLite2Country := filepath.Join(cfg.MMDBLocalPath, "GeoLite2-Country.mmdb")
|
||||||
|
countryDB, err = geoip2.Open(pathGeoLite2Country)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to open Country database:", err)
|
||||||
|
}
|
||||||
|
defer countryDB.Close()
|
||||||
|
|
||||||
|
pathGeoLite2ASN := filepath.Join(cfg.MMDBLocalPath, "GeoLite2-ASN.mmdb")
|
||||||
|
asnDB, err = geoip2.Open(pathGeoLite2ASN)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to open ASN database:", err)
|
||||||
|
}
|
||||||
|
defer asnDB.Close()
|
||||||
|
|
||||||
|
dbFilesVersion = getFileModTime(pathGeoLite2City)
|
||||||
|
|
||||||
|
// Загружаем шаблон
|
||||||
|
templates = template.Must(template.ParseFiles(filepath.Join(cfg.HTMLTemplatePath, "index.html")))
|
||||||
|
|
||||||
|
// Обработка ссылок
|
||||||
|
r := mux.NewRouter()
|
||||||
|
r.HandleFunc("/", homeHandler)
|
||||||
|
r.HandleFunc("/api/{ip}", apiHandler)
|
||||||
|
r.HandleFunc("/api/", apiCurrentHandler)
|
||||||
|
r.HandleFunc("/lookup/{ip}", lookupHandler)
|
||||||
|
r.HandleFunc("/{ip}", lookupHandler)
|
||||||
|
|
||||||
|
log.Println("Server starting on:", cfg.ListenPort)
|
||||||
|
log.Fatal(http.ListenAndServe(":"+cfg.ListenPort, r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFileModTime(filePath string) string {
|
||||||
|
// Получаем информацию о файле
|
||||||
|
fileInfo, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Ошибка в getFileModTime:", err)
|
||||||
|
return "Error"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем время последнего изменения
|
||||||
|
modTime := fileInfo.ModTime()
|
||||||
|
// fmt.Println("Время изменения файла:", modTime)
|
||||||
|
|
||||||
|
// Форматируем вывод
|
||||||
|
// fmt.Printf("Форматированное время: %s\n", modTime.Format("02/01/2006 15:04:05"))
|
||||||
|
return modTime.Format("02/01/2006 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reopenDBs закрывает и заново открывает нужную БД
|
||||||
|
func reopenDBs(fileName string) {
|
||||||
|
dbMutex.Lock()
|
||||||
|
defer dbMutex.Unlock()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch fileName {
|
||||||
|
case "GeoLite2-City.mmdb":
|
||||||
|
if cityDB != nil {
|
||||||
|
cityDB.Close()
|
||||||
|
}
|
||||||
|
// Открываем базы данных заново
|
||||||
|
pathGeoLite2City := filepath.Join(cfg.MMDBLocalPath, fileName)
|
||||||
|
cityDB, err = geoip2.Open(pathGeoLite2City)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ошибка открытия базы данных %v: %v", fileName, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "GeoLite2-Country.mmdb":
|
||||||
|
if countryDB != nil {
|
||||||
|
countryDB.Close()
|
||||||
|
}
|
||||||
|
pathGeoLite2Country := filepath.Join(cfg.MMDBLocalPath, fileName)
|
||||||
|
countryDB, err = geoip2.Open(pathGeoLite2Country)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ошибка открытия базы данных %v: %v", fileName, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "GeoLite2-ASN.mmdb":
|
||||||
|
if asnDB != nil {
|
||||||
|
asnDB.Close()
|
||||||
|
}
|
||||||
|
pathGeoLite2ASN := filepath.Join(cfg.MMDBLocalPath, fileName)
|
||||||
|
asnDB, err = geoip2.Open(pathGeoLite2ASN)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ошибка открытия базы данных %v: %v", fileName, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Println("Открыта база данных:", fileName)
|
||||||
|
dbFilesVersion = getFileModTime(filepath.Join(cfg.MMDBLocalPath, fileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
baseURL := getBaseURL(r)
|
||||||
|
|
||||||
|
// Обрабатываем параметр ip из GET запроса
|
||||||
|
ipStr := r.URL.Query().Get("ip")
|
||||||
|
var geoData *GeoData
|
||||||
|
var errMsg string
|
||||||
|
var whoisData string
|
||||||
|
|
||||||
|
if ipStr != "" {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
errMsg = "Неверный IP адрес: " + ipStr
|
||||||
|
} else {
|
||||||
|
preferredLocale := getPreferredLocale(r)
|
||||||
|
var err error
|
||||||
|
geoData, err = getGeoData(ip, preferredLocale)
|
||||||
|
if err != nil {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
whoisData = getWhoIsInfo(ipStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := TemplateData{
|
||||||
|
GeoData: geoData,
|
||||||
|
WhoIsData: whoisData,
|
||||||
|
Error: errMsg,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
DBVersion: dbFilesVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := templates.ExecuteTemplate(w, "index.html", data)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiCurrentHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Определяем IP клиента
|
||||||
|
ipStr := getClientIP(r)
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
sendJSONError(w, "Unable to determine client IP", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем локаль
|
||||||
|
preferredLocale := getPreferredLocale(r)
|
||||||
|
geoData, err := getGeoData(ip, preferredLocale)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJSONResponse(w, geoData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
ipStr := vars["ip"]
|
||||||
|
|
||||||
|
// Проверяем IP
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
sendJSONError(w, "Invalid IP address", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем локаль
|
||||||
|
preferredLocale := getPreferredLocale(r)
|
||||||
|
geoData, err := getGeoData(ip, preferredLocale)
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJSONResponse(w, geoData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем запросы вида /lookup/{ip} и возвращает HTML страницу с результатами
|
||||||
|
func lookupHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
ipStr := vars["ip"]
|
||||||
|
baseURL := getBaseURL(r)
|
||||||
|
|
||||||
|
// Проверка IP
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
// Если IP невалидный, показываем ошибку на странице
|
||||||
|
templates.ExecuteTemplate(w, "index.html", TemplateData{
|
||||||
|
Error: "Invalid IP address: " + ipStr,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем локаль
|
||||||
|
preferredLocale := getPreferredLocale(r)
|
||||||
|
geoData, err := getGeoData(ip, preferredLocale)
|
||||||
|
if err != nil {
|
||||||
|
templates.ExecuteTemplate(w, "index.html", TemplateData{
|
||||||
|
Error: err.Error(),
|
||||||
|
BaseURL: baseURL,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.MarshalIndent(geoData, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
jsonData = []byte("{\"error\": \"Failed to generate JSON\"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
whoisData := getWhoIsInfo(ipStr)
|
||||||
|
|
||||||
|
// Передаем данные в шаблон
|
||||||
|
templates.ExecuteTemplate(w, "index.html", TemplateData{
|
||||||
|
GeoData: geoData,
|
||||||
|
JSONData: string(jsonData),
|
||||||
|
WhoIsData: whoisData,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
DBVersion: dbFilesVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем локаль броузера
|
||||||
|
func getPreferredLocale(r *http.Request) string {
|
||||||
|
acceptLang := r.Header.Get("Accept-Language")
|
||||||
|
if acceptLang == "" {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, _, err := language.ParseAcceptLanguage(acceptLang)
|
||||||
|
if err != nil || len(tags) == 0 {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
preferred := tags[0]
|
||||||
|
base, _ := preferred.Base()
|
||||||
|
|
||||||
|
locale := base.String()
|
||||||
|
|
||||||
|
supportedLocales := []string{"en", "de", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"}
|
||||||
|
|
||||||
|
for _, supported := range supportedLocales {
|
||||||
|
if locale == supported {
|
||||||
|
return locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращем en если ничего другого не найдено
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendJSONResponse(w http.ResponseWriter, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
// Pretty print JSON with indentation
|
||||||
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
sendJSONError(w, "Failed to encode JSON response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendJSONError(w http.ResponseWriter, message string, statusCode int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
|
||||||
|
errorResponse := map[string]interface{}{
|
||||||
|
"error": map[string]string{
|
||||||
|
"message": message,
|
||||||
|
"code": http.StatusText(statusCode),
|
||||||
|
},
|
||||||
|
"success": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, _ := json.MarshalIndent(errorResponse, "", " ")
|
||||||
|
w.Write(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем адрес клиента
|
||||||
|
func getClientIP(r *http.Request) string {
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
return forwarded
|
||||||
|
}
|
||||||
|
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем доступную локаль
|
||||||
|
func getLocalizedString(names map[string]string, preferredLocale string) string {
|
||||||
|
if names == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем локаль
|
||||||
|
if name, exists := names[preferredLocale]; exists && name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключаем на en
|
||||||
|
if name, exists := names["en"]; exists {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нет Английского возвращем пустоту
|
||||||
|
for _, name := range names {
|
||||||
|
if name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRegisteredCountryData(registeredCountry struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
IsoCode string `maxminddb:"iso_code"`
|
||||||
|
GeoNameID uint `maxminddb:"geoname_id"`
|
||||||
|
IsInEuropeanUnion bool `maxminddb:"is_in_european_union"`
|
||||||
|
}) bool {
|
||||||
|
return registeredCountry.IsoCode != "" || len(registeredCountry.Names) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем данные из БД MaxMind
|
||||||
|
func getGeoData(ip net.IP, preferredLocale string) (*GeoData, error) {
|
||||||
|
dbMutex.RLock()
|
||||||
|
defer dbMutex.RUnlock()
|
||||||
|
|
||||||
|
data := &GeoData{}
|
||||||
|
data.IPAddress = ip.String()
|
||||||
|
data.Metadata.LocaleUsed = preferredLocale
|
||||||
|
|
||||||
|
// Получаем данные из БД City
|
||||||
|
if city, err := cityDB.City(ip); err == nil {
|
||||||
|
data.Metadata.DataSource = "city"
|
||||||
|
|
||||||
|
// Сперва определяем страну (main country)
|
||||||
|
data.Location.Country = getLocalizedString(city.Country.Names, preferredLocale)
|
||||||
|
data.Codes.CountryISO = city.Country.IsoCode
|
||||||
|
|
||||||
|
// Если (main country) пустое то пробуем (registered country)
|
||||||
|
if data.Location.Country == "" && hasRegisteredCountryData(city.RegisteredCountry) {
|
||||||
|
data.Location.Country = getLocalizedString(city.RegisteredCountry.Names, preferredLocale)
|
||||||
|
if data.Codes.CountryISO == "" {
|
||||||
|
data.Codes.CountryISO = city.RegisteredCountry.IsoCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем континет (main continent)
|
||||||
|
data.Location.Continent = getLocalizedString(city.Continent.Names, preferredLocale)
|
||||||
|
data.Codes.ContinentISO = city.Continent.Code
|
||||||
|
|
||||||
|
// Город и данные местоположения
|
||||||
|
data.Location.City = getLocalizedString(city.City.Names, preferredLocale)
|
||||||
|
data.Location.Latitude = city.Location.Latitude
|
||||||
|
data.Location.Longitude = city.Location.Longitude
|
||||||
|
data.Location.TimeZone = city.Location.TimeZone
|
||||||
|
data.Location.PostalCode = city.Postal.Code
|
||||||
|
data.Security.IsAnonymousProxy = city.Traits.IsAnonymousProxy
|
||||||
|
data.Security.IsSatelliteProvider = city.Traits.IsSatelliteProvider
|
||||||
|
|
||||||
|
// Регион
|
||||||
|
if len(city.Subdivisions) > 0 {
|
||||||
|
subdivision := city.Subdivisions[0]
|
||||||
|
data.Location.Region = getLocalizedString(subdivision.Names, preferredLocale)
|
||||||
|
data.Location.RegionISO = subdivision.IsoCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем страну (fallback for country data)
|
||||||
|
if data.Location.Country == "" {
|
||||||
|
if country, err := countryDB.Country(ip); err == nil {
|
||||||
|
if data.Metadata.DataSource == "" {
|
||||||
|
data.Metadata.DataSource = "country"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сперва определяем страну (main country)
|
||||||
|
countryName := getLocalizedString(country.Country.Names, preferredLocale)
|
||||||
|
countryISO := country.Country.IsoCode
|
||||||
|
|
||||||
|
// Если (main country) пустое то пробуем (registered country)
|
||||||
|
if countryName == "" && hasRegisteredCountryData(country.RegisteredCountry) {
|
||||||
|
countryName = getLocalizedString(country.RegisteredCountry.Names, preferredLocale)
|
||||||
|
if countryISO == "" {
|
||||||
|
countryISO = country.RegisteredCountry.IsoCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновлем данные если что нашли
|
||||||
|
if countryName != "" {
|
||||||
|
data.Location.Country = countryName
|
||||||
|
}
|
||||||
|
if countryISO != "" {
|
||||||
|
data.Codes.CountryISO = countryISO
|
||||||
|
}
|
||||||
|
|
||||||
|
// Континент
|
||||||
|
continentName := getLocalizedString(country.Continent.Names, preferredLocale)
|
||||||
|
continentISO := country.Continent.Code
|
||||||
|
|
||||||
|
if continentName != "" {
|
||||||
|
data.Location.Continent = continentName
|
||||||
|
}
|
||||||
|
if continentISO != "" {
|
||||||
|
data.Codes.ContinentISO = continentISO
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прокси
|
||||||
|
data.Security.IsAnonymousProxy = country.Traits.IsAnonymousProxy
|
||||||
|
data.Security.IsSatelliteProvider = country.Traits.IsSatelliteProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем данные из бд ASN
|
||||||
|
if asn, err := asnDB.ASN(ip); err == nil {
|
||||||
|
data.Network.ASN = asn.AutonomousSystemNumber
|
||||||
|
data.Network.ASOrg = asn.AutonomousSystemOrganization
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем базовый URL сайта где запуцщен проект
|
||||||
|
func getBaseURL(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
host := r.Host
|
||||||
|
// Убираем порт если это стандартный порт
|
||||||
|
if strings.HasSuffix(host, ":80") && scheme == "http" {
|
||||||
|
host = strings.TrimSuffix(host, ":80")
|
||||||
|
} else if strings.HasSuffix(host, ":443") && scheme == "https" {
|
||||||
|
host = strings.TrimSuffix(host, ":443")
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос информации с серверов whois
|
||||||
|
func getWhoIsInfo(address string) string {
|
||||||
|
result, err := whois.Whois(address)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "WhoIs request error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка конфигурации
|
||||||
|
func LoadConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
MMDBURL: getEnv("MMDB_URL", "localhost"),
|
||||||
|
MMDBLocalPath: getEnv("MMDB_LOCAL_PATH", "/usr/local/share/geoip/db"),
|
||||||
|
HTMLTemplatePath: getEnv("HTML_TEMPLATE_PATH", "/usr/local/share/geoip/templates"),
|
||||||
|
ListenPort: getEnv("LISTEN_PORT", "8080"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение знаячений переменных окружения
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск задания скачивания файлов
|
||||||
|
func sheduler() {
|
||||||
|
// инициализируем объект планировщика
|
||||||
|
s := gocron.NewScheduler(time.UTC)
|
||||||
|
// добавляем одну задачу на каждую минуту
|
||||||
|
s.Cron("30 2 * * *").Do(startDownload)
|
||||||
|
// s.Cron("*/1 * * * *").Do(startDownload)
|
||||||
|
// запускаем планировщик с блокировкой текущего потока
|
||||||
|
s.StartBlocking()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отслеживает прогресс загрузки
|
||||||
|
type WriteCounter struct {
|
||||||
|
Total uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параллельный запуск загрузки БД
|
||||||
|
func startDownload() {
|
||||||
|
go mmdbDownload("GeoLite2-City.mmdb")
|
||||||
|
go mmdbDownload("GeoLite2-Country.mmdb")
|
||||||
|
go mmdbDownload("GeoLite2-ASN.mmdb")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WriteCounter) Write(p []byte) (int, error) {
|
||||||
|
n := len(p)
|
||||||
|
wc.Total += uint64(n)
|
||||||
|
wc.PrintProgress()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc WriteCounter) PrintProgress() {
|
||||||
|
fmt.Printf("\rЗагружено %d байт...", wc.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка целостности файла.
|
||||||
|
func isValidMMDB(filePath string) bool {
|
||||||
|
db, err := geoip2.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем контрольную сумму локального файла
|
||||||
|
func getFileChecksum(filePath string) (string, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем контрольную сумму удаленного файла (скачиваемого)
|
||||||
|
func getRemoteChecksum(url string) string {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предполагаем, что файл содержит только хеш
|
||||||
|
return strings.TrimSpace(string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldDownload(fileName string) bool {
|
||||||
|
log.Printf("Проверяем контрольную сумму файла %s", fileName)
|
||||||
|
fileURL := cfg.MMDBURL + fileName
|
||||||
|
filePath := filepath.Join(cfg.MMDBLocalPath, fileName)
|
||||||
|
|
||||||
|
// Если файла нет, нужно скачивать
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем возраст файла (скачиваем если старше 7 дней)
|
||||||
|
/*
|
||||||
|
if info, err := os.Stat(filePath); err == nil {
|
||||||
|
if time.Since(info.ModTime()) > 7*24*time.Hour {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
// Проверка по контрольной сумме
|
||||||
|
if checksumURL := fileURL + ".sha256"; checksumURL != "" {
|
||||||
|
remoteFileChecksum := getRemoteChecksum(checksumURL)
|
||||||
|
// fmt.Println("Remote", remoteFileChecksum)
|
||||||
|
|
||||||
|
if remoteFileChecksum != "" {
|
||||||
|
localFileChecksum, error := getFileChecksum(filePath)
|
||||||
|
if error == nil {
|
||||||
|
if localFileChecksum != "" {
|
||||||
|
fmt.Println(filePath, "Remote:", remoteFileChecksum, "Local:", localFileChecksum)
|
||||||
|
fmt.Println(remoteFileChecksum)
|
||||||
|
fmt.Println(localFileChecksum)
|
||||||
|
if localFileChecksum != remoteFileChecksum {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("Error", filePath, error)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка файлов БД с ВЭБ-сервера
|
||||||
|
func mmdbDownload(fileName string) {
|
||||||
|
fileURL := cfg.MMDBURL + fileName
|
||||||
|
filePath := filepath.Join(cfg.MMDBLocalPath, fileName)
|
||||||
|
|
||||||
|
if !shouldDownload(fileName) {
|
||||||
|
log.Printf("Файл %s актуален, пропускаем загрузку", fileName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скачиваем во временный файл
|
||||||
|
tempPath := filePath + ".tmp"
|
||||||
|
|
||||||
|
log.Println("Загружается файл:", fileURL)
|
||||||
|
// Создаём файл
|
||||||
|
file, err := os.Create(tempPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ошибка создания файла %s: %v", tempPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Загружаем данные с отслеживанием прогресса
|
||||||
|
counter := &WriteCounter{}
|
||||||
|
response, err := http.Get(fileURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ошибка загрузки файла %s: %v", fileURL, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
log.Printf("Ошибка загрузки: %s", response.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копируем через буфер с отслеживанием прогресса
|
||||||
|
_, err = io.Copy(file, io.TeeReader(response.Body, counter))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ошибка копирования данных: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Загрузка завершена! Файл сохранен:", tempPath)
|
||||||
|
|
||||||
|
// Переоткрываем базы данных после успешной загрузки
|
||||||
|
// reopenDBs(fileName)
|
||||||
|
// Проверяем валидность перед заменой
|
||||||
|
if isValidMMDB(tempPath) {
|
||||||
|
// Заменяем старый файл
|
||||||
|
os.Rename(tempPath, filePath)
|
||||||
|
// Переоткрываем только эту базу
|
||||||
|
reopenDBs(fileName)
|
||||||
|
} else {
|
||||||
|
log.Printf("Скачанный файл %s поврежден, удаляем", fileName)
|
||||||
|
os.Remove(tempPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
502
templates/index.html
Normal file
502
templates/index.html
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Поиск по GeoIP и Whois</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #007bff;
|
||||||
|
}
|
||||||
|
.result h3 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.json-view {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: #ecf0f1;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.locale-info {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #856404;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.examples {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.examples h3 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.example-ips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.example-ip {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid #007bff;
|
||||||
|
}
|
||||||
|
.example-ip:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
background: white;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.data-section {
|
||||||
|
margin: 15px 0;
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.data-section h4 {
|
||||||
|
color: #007bff;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.data-section hr {
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
background-color: #dee2e6;
|
||||||
|
color: #dee2e6;
|
||||||
|
}
|
||||||
|
.data-item {
|
||||||
|
margin: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
.data-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 150px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.data-value {
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.locale-selector {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
.locale-selector label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: white;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
.filters h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.search-button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.search-button:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
.ip-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.whois-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: #17a2b8;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.whois-link:hover {
|
||||||
|
background: #138496;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.alarm-action-true {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.alarm-action-false {
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.input-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.data-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.data-label {
|
||||||
|
min-width: auto;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
background: white;
|
||||||
|
margin-top: auto;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.footer-links, .header-links {
|
||||||
|
margin-top: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.header-links {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.current-url {
|
||||||
|
background: #e9ecef;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-container">
|
||||||
|
<a href={{.BaseURL}} class="header-links"><h1>🌍 Поиск по GeoIP и Whois</h1></a>
|
||||||
|
<div class="locale-selector">
|
||||||
|
<label for="locale">Язык:</label>
|
||||||
|
<select id="locale">
|
||||||
|
<option value="auto">Авто</option>
|
||||||
|
<option value="en">Английский</option>
|
||||||
|
<option value="de">Немецкий</option>
|
||||||
|
<option value="es">Испанский</option>
|
||||||
|
<option value="fr">Французский</option>
|
||||||
|
<option value="ja">Японский</option>
|
||||||
|
<option value="pt-BR">Португальский (Бразилия)</option>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="zh-CN">Китайский</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="filters">
|
||||||
|
<h3>Введите IP адрес</h3>
|
||||||
|
<form method="GET" action="/">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="ip" placeholder="Введите IP адрес (например, 8.8.8.8)"
|
||||||
|
value="{{if .GeoData}}{{.GeoData.IPAddress}}{{end}}">
|
||||||
|
<button type="submit">Найти</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<!--
|
||||||
|
<button onclick="lookupMyIp()" style="background: #28a745;">Lookup My IP</button>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result">
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error">
|
||||||
|
<strong>Error:</strong> {{.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .GeoData}}
|
||||||
|
<!--
|
||||||
|
<div class="result">
|
||||||
|
<div class="success">✓ Data retrieved successfully</div>
|
||||||
|
{{if .Metadata.LocaleUsed}}
|
||||||
|
<div class="locale-info">🌐 Display language: {{.Metadata.LocaleUsed}}</div>
|
||||||
|
{{end}}
|
||||||
|
-->
|
||||||
|
<h3>Результат поиска для {{.IPAddress}}</h3>
|
||||||
|
|
||||||
|
<div class="data-section">
|
||||||
|
<b>🌐 Местоположение:</b>
|
||||||
|
<br><br>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Страна:</div>
|
||||||
|
<div class="data-value">{{if .Location.Country}}{{.Location.Country}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Регион:</div>
|
||||||
|
<div class="data-value">{{if .Location.Region}}{{.Location.Region}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Код региона :</div>
|
||||||
|
<div class="data-value">{{if .Location.RegionISO}}{{.Location.RegionISO}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Город:</div>
|
||||||
|
<div class="data-value">{{if .Location.City}}{{.Location.City}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Континент:</div>
|
||||||
|
<div class="data-value">{{if .Location.Continent}}{{.Location.Continent}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Координаты:</div>
|
||||||
|
<div class="data-value">
|
||||||
|
{{if .Location.Latitude}}{{.Location.Latitude}}, {{.Location.Longitude}}{{else}}<span class="no-data">N/A</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Временная зона:</div>
|
||||||
|
<div class="data-value">{{if .Location.TimeZone}}{{.Location.TimeZone}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Почтовый индекс:</div>
|
||||||
|
<div class="data-value">{{if .Location.PostalCode}}{{.Location.PostalCode}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-section">
|
||||||
|
<b>🌐 Информация о операторе:</b>
|
||||||
|
<br><br>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">ASN:</div>
|
||||||
|
<div class="data-value">{{if .Network.ASN}}{{.Network.ASN}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Организация:</div>
|
||||||
|
<div class="data-value">{{if .Network.ASOrg}}{{.Network.ASOrg}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-section">
|
||||||
|
<b>📋 Международный код (ISO):</b>
|
||||||
|
<br><br>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Код страны:</div>
|
||||||
|
<div class="data-value">{{if .Codes.CountryISO}}{{.Codes.CountryISO}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Код континента:</div>
|
||||||
|
<div class="data-value">{{if .Codes.ContinentISO}}{{.Codes.ContinentISO}}{{else}}<span class="no-data">N/A</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-section">
|
||||||
|
<b>🔒 Дополнительная информация:</b>
|
||||||
|
<br><br>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Анонимный прокси:</div>
|
||||||
|
<div class="data-value {{if .Security.IsAnonymousProxy}}alarm-action-true{{else}}alarm-action-false{{end}}">
|
||||||
|
{{if .Security.IsAnonymousProxy}}Yes{{else}}No{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-item">
|
||||||
|
<div class="data-label">Спутниковый провайдер:</div>
|
||||||
|
<div class="data-value {{if .Security.IsSatelliteProvider}}alarm-action-true{{else}}alarm-action-false{{end}}">
|
||||||
|
{{if .Security.IsSatelliteProvider}}Yes{{else}}No{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
{{if .JSONData}}
|
||||||
|
<div class="data-section">
|
||||||
|
<b>📄 Полученные данные (JSON)</b>
|
||||||
|
<div class="json-view">{{.JSONData}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
-->
|
||||||
|
{{if .WhoIsData}}
|
||||||
|
<div class="data-section">
|
||||||
|
<b>🌐 Данные Whois:</b>
|
||||||
|
<br><br>
|
||||||
|
<div class="json-view">{{.WhoIsData}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="examples">
|
||||||
|
<h3>Дополнительные команды:</h3>
|
||||||
|
<p>Данный сервис можно использовать для получения данных в JSON:
|
||||||
|
<div class="current-url">
|
||||||
|
<span id="current-url-display">{{.BaseURL}}/api/x.x.x.x</span>
|
||||||
|
</div>
|
||||||
|
<p>Для получения информации ввиде html:
|
||||||
|
<div class="current-url">
|
||||||
|
<span id="current-url-display">{{.BaseURL}}/x.x.x.x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<footer>
|
||||||
|
<div>© <script>document.write(new Date().getFullYear())</script> СВК</div>
|
||||||
|
<div>Данные предоставлены MaxMind. Последнее обновление - {{.DBVersion}}</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user