[优化] 改名

This commit is contained in:
ryan
2026-03-15 16:04:54 +08:00
parent d68773c554
commit 32d90ba641
304 changed files with 629 additions and 969 deletions
+23
View File
@@ -0,0 +1,23 @@
package utils
import (
"log/slog"
"os/exec"
"runtime"
)
func OpenBrowser(url string) {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
}
if err != nil {
slog.Error("open browser failed", "error", err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package embedfs
import (
"embed"
"io/fs"
"net/http"
"strings"
"github.com/gin-contrib/static"
)
// Credit: https://github.com/gin-contrib/static/issues/19
type fileSystem struct {
http.FileSystem
}
func (e fileSystem) Exists(prefix string, path string) bool {
cleanPath := strings.TrimPrefix(path, prefix)
cleanPath = strings.TrimPrefix(cleanPath, "/")
if cleanPath == "" {
return false
}
_, err := e.Open(cleanPath)
if err != nil {
return false
}
return true
}
func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
efs, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
panic(err)
}
return fileSystem{
FileSystem: http.FS(efs),
}
}
+53
View File
@@ -0,0 +1,53 @@
package utils
import (
"fmt"
"strconv"
)
var sizeKB = 1024
var sizeMB = sizeKB * 1024
var sizeGB = sizeMB * 1024
func Bytes2Size(num int64) string {
numStr := ""
unit := "B"
if num/int64(sizeGB) > 1 {
numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
unit = "GB"
} else if num/int64(sizeMB) > 1 {
numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB)))
unit = "MB"
} else if num/int64(sizeKB) > 1 {
numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB)))
unit = "KB"
} else {
numStr = fmt.Sprintf("%d", num)
}
return numStr + " " + unit
}
func Seconds2Time(num int) (time string) {
if num/31104000 > 0 {
time += strconv.Itoa(num/31104000) + " 年 "
num %= 31104000
}
if num/2592000 > 0 {
time += strconv.Itoa(num/2592000) + " 个月 "
num %= 2592000
}
if num/86400 > 0 {
time += strconv.Itoa(num/86400) + " 天 "
num %= 86400
}
if num/3600 > 0 {
time += strconv.Itoa(num/3600) + " 小时 "
num %= 3600
}
if num/60 > 0 {
time += strconv.Itoa(num/60) + " 分钟 "
num %= 60
}
time += strconv.Itoa(num) + " 秒"
return
}
@@ -0,0 +1,25 @@
package geoip
import (
"fmt"
"net"
)
type EmptyProvider struct{}
func (e *EmptyProvider) Name() string {
return "EmptyProvider"
}
func (e *EmptyProvider) Initialize() error {
return nil
}
func (e *EmptyProvider) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
return nil, fmt.Errorf("you are using an empty GeoIP provider, please set a valid provider")
}
func (e *EmptyProvider) UpdateDatabase() error {
return fmt.Errorf("you are using an empty GeoIP provider, please set a valid provider")
}
func (e *EmptyProvider) Close() error {
return nil
}
+243
View File
@@ -0,0 +1,243 @@
package geoip
import (
"fmt"
"log/slog"
"net"
"openflare/common"
"strings"
"sync"
"time"
"unicode"
ristretto "github.com/dgraph-io/ristretto/v2"
)
var CurrentProvider GeoIPService
var geoCache *providerCache
var providerMutex sync.RWMutex
var providerFactory = newProvider
const (
ProviderDisabled = "disabled"
ProviderMaxMind = "mmdb"
ProviderIPAPI = "ip-api"
ProviderGeoJS = "geojs"
ProviderIPInfo = "ipinfo"
)
type GeoInfo struct {
ISOCode string
Name string
Latitude *float64
Longitude *float64
}
func init() {
CurrentProvider = &EmptyProvider{}
geoCache = newProviderCache(48 * time.Hour)
}
// GeoIPService 接口定义了获取地理位置信息的核心方法。
type GeoIPService interface {
Name() string
GetGeoInfo(ip net.IP) (*GeoInfo, error)
UpdateDatabase() error
Close() error
}
type cachedGeoInfo struct {
info *GeoInfo
expiresAt time.Time
}
type providerCache struct {
items *ristretto.Cache[string, cachedGeoInfo]
duration time.Duration
}
func newProviderCache(duration time.Duration) *providerCache {
items, err := ristretto.NewCache(&ristretto.Config[string, cachedGeoInfo]{
NumCounters: 1e5,
MaxCost: 2e4,
BufferItems: 64,
})
if err != nil {
panic(err)
}
return &providerCache{
items: items,
duration: duration,
}
}
func (c *providerCache) Get(key string) (*GeoInfo, bool) {
entry, ok := c.items.Get(key)
if !ok {
return nil, false
}
if time.Now().After(entry.expiresAt) {
c.items.Del(key)
return nil, false
}
return entry.info, true
}
func (c *providerCache) Set(key string, info *GeoInfo) {
c.items.Set(key, cachedGeoInfo{
info: info,
expiresAt: time.Now().Add(c.duration),
}, 1)
c.items.Wait()
}
func (c *providerCache) Flush() {
c.items.Clear()
}
func GetRegionUnicodeEmoji(isoCode string) string {
if len(isoCode) != 2 {
return ""
}
isoCode = strings.ToUpper(isoCode)
if !unicode.IsLetter(rune(isoCode[0])) || !unicode.IsLetter(rune(isoCode[1])) {
return ""
}
rune1 := rune(0x1F1E6 + (rune(isoCode[0]) - 'A'))
rune2 := rune(0x1F1E6 + (rune(isoCode[1]) - 'A'))
return string(rune1) + string(rune2)
}
func InitGeoIP() {
providerName := normalizeProvider(common.GeoIPProvider)
nextProvider, err := providerFactory(providerName)
if err != nil {
slog.Error("initialize GeoIP provider failed", "provider", providerName, "error", err)
nextProvider = &EmptyProvider{}
}
setProvider(nextProvider)
if providerName == ProviderDisabled {
slog.Info("GeoIP provider disabled")
return
}
slog.Info("GeoIP provider configured", "provider", CurrentProvider.Name())
}
func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
if ip == nil {
return nil, fmt.Errorf("IP address cannot be nil")
}
provider := getProvider()
cacheKey := provider.Name() + ":" + ip.String()
if cachedInfo, found := geoCache.Get(cacheKey); found {
return cachedInfo, nil
}
info, err := provider.GetGeoInfo(ip)
if err == nil && info != nil {
geoCache.Set(cacheKey, info)
}
return info, err
}
func LookupGeoInfoWithProvider(providerName string, ip net.IP) (*GeoInfo, error) {
if ip == nil {
return nil, fmt.Errorf("IP address cannot be nil")
}
provider, err := providerFactory(normalizeProvider(providerName))
if err != nil {
return nil, err
}
defer func() {
if closeErr := provider.Close(); closeErr != nil {
slog.Warn("close temporary GeoIP provider failed", "provider", provider.Name(), "error", closeErr)
}
}()
return provider.GetGeoInfo(ip)
}
func UpdateDatabase() error {
err := getProvider().UpdateDatabase()
if err == nil {
geoCache.Flush()
slog.Info("GeoIP cache cleared due to database update.")
}
return err
}
func IsValidProvider(provider string) bool {
switch normalizeProvider(provider) {
case ProviderDisabled, ProviderMaxMind, ProviderIPAPI, ProviderGeoJS, ProviderIPInfo:
return true
default:
return false
}
}
func normalizeProvider(provider string) string {
normalized := strings.TrimSpace(strings.ToLower(provider))
if normalized == "" {
return ProviderDisabled
}
return normalized
}
func newProvider(provider string) (GeoIPService, error) {
switch provider {
case ProviderDisabled:
return &EmptyProvider{}, nil
case ProviderMaxMind:
return NewMaxMindGeoIPService()
case ProviderIPAPI:
return NewIPAPIService()
case ProviderGeoJS:
return NewGeoJSService()
case ProviderIPInfo:
return NewIPInfoService()
default:
return nil, fmt.Errorf("unsupported GeoIP provider %q", provider)
}
}
func setProvider(provider GeoIPService) {
providerMutex.Lock()
previous := CurrentProvider
CurrentProvider = provider
providerMutex.Unlock()
geoCache.Flush()
if previous != nil && previous != provider {
if err := previous.Close(); err != nil {
slog.Warn("close previous GeoIP provider failed", "error", err)
}
}
}
func getProvider() GeoIPService {
providerMutex.RLock()
defer providerMutex.RUnlock()
if CurrentProvider == nil {
return &EmptyProvider{}
}
return CurrentProvider
}
func float64Pointer(value float64) *float64 {
return &value
}
func ProviderFactoryForTest() func(string) (GeoIPService, error) {
return providerFactory
}
func SetProviderFactoryForTest(factory func(string) (GeoIPService, error)) {
if factory == nil {
providerFactory = newProvider
return
}
providerFactory = factory
}
@@ -0,0 +1,99 @@
package geoip
import (
"net"
"testing"
)
type fakeProvider struct {
calls int
}
func (f *fakeProvider) Name() string {
return "fake"
}
func (f *fakeProvider) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
f.calls++
return &GeoInfo{
ISOCode: "CN",
Name: "China",
}, nil
}
func (f *fakeProvider) UpdateDatabase() error {
return nil
}
func (f *fakeProvider) Close() error {
return nil
}
func TestGetGeoInfoCachesByProviderAndIP(t *testing.T) {
originalProvider := CurrentProvider
geoCache.Flush()
fake := &fakeProvider{}
CurrentProvider = fake
defer func() {
CurrentProvider = originalProvider
}()
ip := net.ParseIP("8.8.8.8")
record, err := GetGeoInfo(ip)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if record == nil || record.ISOCode != "CN" {
t.Fatalf("expected cached record, got %#v", record)
}
_, err = GetGeoInfo(ip)
if err != nil {
t.Fatalf("expected nil error on second call, got %v", err)
}
if fake.calls != 1 {
t.Fatalf("expected provider to be called once, got %d", fake.calls)
}
}
func TestUnicodeEmoji(t *testing.T) {
emoji := GetRegionUnicodeEmoji("CN")
if emoji != "🇨🇳" {
t.Errorf("expected emoji for CN, got %s", emoji)
}
}
func TestIsValidProvider(t *testing.T) {
cases := map[string]bool{
"disabled": true,
"mmdb": true,
"ip-api": true,
"geojs": true,
"ipinfo": true,
"unknown": false,
}
for provider, want := range cases {
if got := IsValidProvider(provider); got != want {
t.Fatalf("provider %s validity mismatch: want %v, got %v", provider, want, got)
}
}
}
func TestLookupGeoInfoWithProviderUsesTemporaryProvider(t *testing.T) {
previousFactory := providerFactory
providerFactory = func(provider string) (GeoIPService, error) {
return &fakeProvider{}, nil
}
defer func() {
providerFactory = previousFactory
}()
info, err := LookupGeoInfoWithProvider("ipinfo", net.ParseIP("8.8.8.8"))
if err != nil {
t.Fatalf("expected lookup to succeed, got %v", err)
}
if info == nil || info.ISOCode != "CN" || info.Name != "China" {
t.Fatalf("unexpected geo info: %#v", info)
}
}
+84
View File
@@ -0,0 +1,84 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// GeoJSService 使用 geojs.io 服务实现 GeoIPService 接口。
type GeoJSService struct {
Client *http.Client
}
// geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。
// 我们只定义我们需要的字段。
type geoJSResponse struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
Latitude float64 `json:"latitude,string"`
Longitude float64 `json:"longitude,string"`
// 可以根据需要添加其他字段,例如:
// City string `json:"city"`
// Region string `json:"region"`
}
// NewGeoJSService 创建并返回一个 GeoJSService 的新实例。
func NewGeoJSService() (*GeoJSService, error) {
return &GeoJSService{
Client: &http.Client{
Timeout: 5 * time.Second, // 设置一个合理的超时时间
},
}, nil
}
// Name 返回服务的名称。
func (s *GeoJSService) Name() string {
return "geojs.io"
}
// GetGeoInfo 使用 geojs.io 服务检索给定 IP 地址的地理位置信息。
func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// GeoJS 的 API 端点
apiURL := fmt.Sprintf("https://get.geojs.io/v1/ip/geo/%s.json", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from geojs.io: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("geojs.io returned non-200 status code: %d", resp.StatusCode)
}
var apiResp geoJSResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode geojs.io response: %w", err)
}
// 检查国家代码是否为空,因为 geojs 对无效/私有IP可能返回200 OK但内容为空
if apiResp.CountryCode == "" {
return nil, fmt.Errorf("geojs.io returned empty geo info for ip: %s", ip.String())
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
Latitude: float64Pointer(apiResp.Latitude),
Longitude: float64Pointer(apiResp.Longitude),
}, nil
}
// UpdateDatabase 对于 geojs.io 是一个空操作,因为它是一个 Web 服务。
func (s *GeoJSService) UpdateDatabase() error {
return nil
}
// Close 对于 geojs.io 是一个空操作。
func (s *GeoJSService) Close() error {
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// IPAPIService 使用 ip-api.com 服务实现 GeoIPService 接口。
type IPAPIService struct {
Client *http.Client
}
// ipAPIResponse 定义了 ip-api.com 服务返回的 JSON 响应的结构。
type ipAPIResponse struct {
Status string `json:"status"`
Message string `json:"message"` // 当 status 为 fail 时出现
Country string `json:"country"`
CountryCode string `json:"countryCode"`
Region string `json:"region"`
RegionName string `json:"regionName"`
City string `json:"city"`
Zip string `json:"zip"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Timezone string `json:"timezone"`
ISP string `json:"isp"`
Org string `json:"org"`
As string `json:"as"`
Query string `json:"query"`
}
func (s *IPAPIService) Name() string {
return "ip-api.com"
}
// NewIPAPIService 创建并返回一个 IPAPIService 的新实例。
func NewIPAPIService() (*IPAPIService, error) {
return &IPAPIService{
Client: &http.Client{
Timeout: 5 * time.Second, // 设置请求超时
},
}, nil
}
// GetGeoInfo 使用 ip-api.com 服务检索给定 IP 地址的地理位置信息。
func (s *IPAPIService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// API URL, 使用 fields 参数来仅请求需要的字段
apiURL := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,countryCode", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from ip-api.com: %w", err)
}
defer resp.Body.Close()
var apiResp ipAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode ip-api.com response: %w", err)
}
if apiResp.Status != "success" {
return nil, fmt.Errorf("ip-api.com returned an error: %s", apiResp.Message)
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
Latitude: float64Pointer(apiResp.Lat),
Longitude: float64Pointer(apiResp.Lon),
}, nil
}
// UpdateDatabase 对于 ip-api.com 是一个空操作,因为它是一个 Web 服务。
func (s *IPAPIService) UpdateDatabase() error {
// 无需执行任何操作,因为数据由外部服务提供
return nil
}
// Close 对于 ip-api.com 是一个空操作,因为没有需要关闭的持久连接。
func (s *IPAPIService) Close() error {
// 无需执行任何操作
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
)
// IPInfoService 使用 ipinfo.io 服务实现 GeoIPService 接口。
type IPInfoService struct {
Client *http.Client
// 每天 1000 次请求,限制由 IP 地址的所有人共享。
// APIToken string
}
// ipInfoResponse 定义了 ipinfo.io 服务返回的 JSON 响应的结构,只包含免费额度可用的字段。
type ipInfoResponse struct {
IP string `json:"ip"`
Hostname string `json:"hostname"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
CountryCode string `json:"countryCode"` // ipinfo.io 返回 "country" 的 ISO 代码,这里为了与 GeoInfo 保持一致,额外添加一个 CountryCode
Loc string `json:"loc"` // Latitude,Longitude
Org string `json:"org"`
Postal string `json:"postal"`
Timezone string `json:"timezone"`
}
// NewIPInfoService 创建并返回一个 IPInfoService 的新实例。
func NewIPInfoService() (*IPInfoService, error) {
return &IPInfoService{
Client: &http.Client{
Timeout: 5 * time.Second,
},
}, nil
}
// Name 返回服务的名称。
func (s *IPInfoService) Name() string {
return "ipinfo.io"
}
// GetGeoInfo 使用 ipinfo.io 服务检索给定 IP 地址的地理位置信息。
// 免费额度主要提供国家信息。
func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// IPinfo 免费额度不需要 API token 就可以查询基本的 IP 信息。
// API URL: https://ipinfo.io/json (查询自身IP) 或 https://ipinfo.io/YOUR_IP/json
apiURL := fmt.Sprintf("https://ipinfo.io/%s/json", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from ipinfo.io: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ipinfo.io returned non-200 status: %d %s", resp.StatusCode, resp.Status)
}
var apiResp ipInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode ipinfo.io response: %w", err)
}
latitude, longitude := parseIPInfoCoordinates(apiResp.Loc)
// IPinfo 的 "country" 字段直接返回 ISO 2-letter code,例如 "US", "CN"
// 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。
// IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。
// 为了简化并符合 GeoInfo 结构,我们直接使用 Country 作为 ISOCode,并尝试从 CountryCode 获取名称。
// 实际上,IPinfo 的 'country' 字段就是 ISO 2-letter code。
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
return &GeoInfo{
ISOCode: apiResp.Country,
Name: apiResp.Country,
Latitude: latitude,
Longitude: longitude,
}, nil
}
// UpdateDatabase 对于 ipinfo.io 是一个空操作,因为它是一个 Web 服务。
func (s *IPInfoService) UpdateDatabase() error {
// 无需执行任何操作,因为数据由外部服务提供
return nil
}
// Close 对于 ipinfo.io 是一个空操作,因为没有需要关闭的持久连接。
func (s *IPInfoService) Close() error {
// 无需执行任何操作
return nil
}
func parseIPInfoCoordinates(value string) (*float64, *float64) {
parts := strings.Split(strings.TrimSpace(value), ",")
if len(parts) != 2 {
return nil, nil
}
latitudeValue, latErr := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
longitudeValue, lonErr := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if latErr != nil || lonErr != nil {
return nil, nil
}
return float64Pointer(latitudeValue), float64Pointer(longitudeValue)
}
+151
View File
@@ -0,0 +1,151 @@
package geoip
import (
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"github.com/oschwald/maxminddb-golang"
)
var GeoIpUrl = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
var GeoIpFilePath = "./data/GeoLite2-Country.mmdb"
type GeoIpRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
}
type MaxMindGeoIPService struct {
maxMindDBReader *maxminddb.Reader
dbFilePath string
mu sync.RWMutex
}
func (s *MaxMindGeoIPService) Name() string {
return "MaxMind"
}
func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) {
service := &MaxMindGeoIPService{
dbFilePath: GeoIpFilePath,
}
if err := os.MkdirAll(filepath.Dir(service.dbFilePath), os.ModePerm); err != nil {
return nil, fmt.Errorf("failed to create data directory for MaxMind database: %w", err)
}
if _, err := os.Stat(service.dbFilePath); os.IsNotExist(err) {
if err := service.UpdateDatabase(); err != nil {
return nil, fmt.Errorf("failed to download initial MaxMind database: %w", err)
}
}
if err := service.initialize(); err != nil {
return nil, fmt.Errorf("failed to initialize MaxMind database: %w", err)
}
return service, nil
}
func (s *MaxMindGeoIPService) initialize() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.maxMindDBReader != nil {
_ = s.maxMindDBReader.Close()
s.maxMindDBReader = nil
}
reader, err := maxminddb.Open(s.dbFilePath)
if err != nil {
return fmt.Errorf("error opening MaxMind database at %s: %w", s.dbFilePath, err)
}
s.maxMindDBReader = reader
return nil
}
func (s *MaxMindGeoIPService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.maxMindDBReader == nil {
return nil, fmt.Errorf("MaxMind database is not initialized or failed to open")
}
if ip == nil {
return nil, fmt.Errorf("IP address cannot be nil")
}
var record GeoIpRecord
if err := s.maxMindDBReader.Lookup(ip, &record); err != nil {
return nil, fmt.Errorf("error looking up IP %s in MaxMind database: %w", ip.String(), err)
}
geoInfo := &GeoInfo{
ISOCode: record.Country.ISOCode,
Name: record.Country.Names["en"],
}
if geoInfo.Name == "" && geoInfo.ISOCode != "" {
geoInfo.Name = geoInfo.ISOCode
}
return geoInfo, nil
}
func (s *MaxMindGeoIPService) UpdateDatabase() error {
resp, err := http.Get(GeoIpUrl)
if err != nil {
return fmt.Errorf("failed to initiate MaxMind database download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download MaxMind database: HTTP status %s", resp.Status)
}
if err := os.MkdirAll(filepath.Dir(s.dbFilePath), os.ModePerm); err != nil {
return fmt.Errorf("failed to create data directory for MaxMind database update: %w", err)
}
tempPath := s.dbFilePath + ".download"
out, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("failed to create MaxMind database file at %s: %w", tempPath, err)
}
defer func() {
_ = out.Close()
}()
if _, err = io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write MaxMind database file: %w", err)
}
if err = out.Close(); err != nil {
return fmt.Errorf("failed to close MaxMind database file: %w", err)
}
if err = os.Rename(tempPath, s.dbFilePath); err != nil {
return fmt.Errorf("failed to move MaxMind database file into place: %w", err)
}
return s.initialize()
}
func (s *MaxMindGeoIPService) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.maxMindDBReader != nil {
err := s.maxMindDBReader.Close()
s.maxMindDBReader = nil
if err != nil {
return fmt.Errorf("error closing MaxMind database: %w", err)
}
}
slog.Info("MaxMind GeoIP service closed.")
return nil
}
+7
View File
@@ -0,0 +1,7 @@
package utils
import "html/template"
func UnescapeHTML(x string) interface{} {
return template.HTML(x)
}
+65
View File
@@ -0,0 +1,65 @@
package mail
import (
"crypto/tls"
"encoding/base64"
"fmt"
"net/smtp"
"openflare/common"
"strings"
)
func SendEmail(subject string, receiver string, content string) error {
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
mail := []byte(fmt.Sprintf("To: %s\r\n"+
"From: %s<%s>\r\n"+
"Subject: %s\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
receiver, common.SystemName, common.SMTPAccount, encodedSubject, content))
auth := smtp.PlainAuth("", common.SMTPAccount, common.SMTPToken, common.SMTPServer)
addr := fmt.Sprintf("%s:%d", common.SMTPServer, common.SMTPPort)
to := strings.Split(receiver, ";")
var err error
if common.SMTPPort == 465 {
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: common.SMTPServer,
}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", common.SMTPServer, common.SMTPPort), tlsConfig)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, common.SMTPServer)
if err != nil {
return err
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return err
}
if err = client.Mail(common.SMTPAccount); err != nil {
return err
}
receiverEmails := strings.Split(receiver, ";")
for _, receiver := range receiverEmails {
if err = client.Rcpt(receiver); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(mail)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
} else {
err = smtp.SendMail(addr, auth, common.SMTPAccount, to, mail)
}
return err
}
+15
View File
@@ -0,0 +1,15 @@
package utils
func IntMax(a int, b int) int {
if a >= b {
return a
}
return b
}
func Max(a int, b int) int {
if a >= b {
return a
}
return b
}
+34
View File
@@ -0,0 +1,34 @@
package utils
import (
"log/slog"
"net"
"strings"
)
func GetIp() (ip string) {
ips, err := net.InterfaceAddrs()
if err != nil {
slog.Error("get interface addresses failed", "error", err)
return ip
}
for _, a := range ips {
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
ip = ipNet.IP.String()
if strings.HasPrefix(ip, "10") {
return
}
if strings.HasPrefix(ip, "172") {
return
}
if strings.HasPrefix(ip, "192.168") {
return
}
ip = ""
}
}
}
return
}
@@ -0,0 +1,67 @@
package ratelimit
import (
"sync"
"time"
)
type InMemoryRateLimiter struct {
store map[string]*[]int64
mutex sync.Mutex
expirationDuration time.Duration
}
func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
if l.store == nil {
l.mutex.Lock()
if l.store == nil {
l.store = make(map[string]*[]int64)
l.expirationDuration = expirationDuration
if expirationDuration > 0 {
go l.clearExpiredItems()
}
}
l.mutex.Unlock()
}
}
func (l *InMemoryRateLimiter) clearExpiredItems() {
for {
time.Sleep(l.expirationDuration)
l.mutex.Lock()
now := time.Now().Unix()
for key := range l.store {
queue := l.store[key]
size := len(*queue)
if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
delete(l.store, key)
}
}
l.mutex.Unlock()
}
}
// Request parameter duration's unit is seconds
func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
l.mutex.Lock()
defer l.mutex.Unlock()
// [old <-- new]
queue, ok := l.store[key]
now := time.Now().Unix()
if ok {
if len(*queue) < maxRequestNum {
*queue = append(*queue, now)
return true
}
if now-(*queue)[0] >= duration {
*queue = (*queue)[1:]
*queue = append(*queue, now)
return true
}
return false
}
s := make([]int64, 0, maxRequestNum)
l.store[key] = &s
*(l.store[key]) = append(*(l.store[key]), now)
return true
}
@@ -0,0 +1,14 @@
package security
import "golang.org/x/crypto/bcrypt"
func Password2Hash(password string) (string, error) {
passwordBytes := []byte(password)
hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
return string(hashedPassword), err
}
func ValidatePasswordAndHash(password string, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
+37
View File
@@ -0,0 +1,37 @@
package security
import "crypto/rand"
func GenerateRandomString(length int) string {
if length <= 0 {
return ""
}
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const n = byte(len(charset))
const threshold = byte(256 - (256 % len(charset)))
out := make([]byte, 0, length)
buf := make([]byte, length)
for len(out) < length {
if _, err := rand.Read(buf); err != nil {
return ""
}
for _, b := range buf {
if b < threshold {
out = append(out, charset[int(b%n)])
if len(out) == length {
break
}
}
}
}
return string(out)
}
func GeneratePassword() string {
return GenerateRandomString(12)
}
func GenerateToken() string {
return GenerateRandomString(22)
}
@@ -0,0 +1,78 @@
package security
import (
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type verificationValue struct {
code string
time time.Time
}
const (
EmailVerificationPurpose = "v"
PasswordResetPurpose = "r"
)
var verificationMutex sync.Mutex
var verificationMap map[string]verificationValue
var verificationMapMaxSize = 10
var VerificationValidMinutes = 10
func GenerateVerificationCode(length int) string {
code := uuid.New().String()
code = strings.Replace(code, "-", "", -1)
if length == 0 {
return code
}
return code[:length]
}
func RegisterVerificationCodeWithKey(key string, code string, purpose string) {
verificationMutex.Lock()
defer verificationMutex.Unlock()
verificationMap[purpose+key] = verificationValue{
code: code,
time: time.Now(),
}
if len(verificationMap) > verificationMapMaxSize {
removeExpiredPairs()
}
}
func VerifyCodeWithKey(key string, code string, purpose string) bool {
verificationMutex.Lock()
defer verificationMutex.Unlock()
value, okay := verificationMap[purpose+key]
now := time.Now()
if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
return false
}
return code == value.code
}
func DeleteKey(key string, purpose string) {
verificationMutex.Lock()
defer verificationMutex.Unlock()
delete(verificationMap, purpose+key)
}
// no lock inside, so the caller must lock the verificationMap before calling!
func removeExpiredPairs() {
now := time.Now()
for key := range verificationMap {
if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {
delete(verificationMap, key)
}
}
}
func init() {
verificationMutex.Lock()
defer verificationMutex.Unlock()
verificationMap = make(map[string]verificationValue)
}
+13
View File
@@ -0,0 +1,13 @@
package utils
import (
"strings"
"github.com/google/uuid"
)
func GetUUID() string {
code := uuid.New().String()
code = strings.Replace(code, "-", "", -1)
return code
}
@@ -0,0 +1,9 @@
package validation
import "github.com/go-playground/validator/v10"
var Validate *validator.Validate
func init() {
Validate = validator.New()
}
+15
View File
@@ -0,0 +1,15 @@
package utils
import "fmt"
func Interface2String(inter interface{}) string {
switch inter.(type) {
case string:
return inter.(string)
case int:
return fmt.Sprintf("%d", inter.(int))
case float64:
return fmt.Sprintf("%f", inter.(float64))
}
return "Not Implemented"
}