mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
feat(geolite): implement GeoLite2 database download from cloud
This commit is contained in:
@@ -17,7 +17,9 @@
|
||||
"Bash(cp:*)",
|
||||
"mcp__eslint__lint-files",
|
||||
"Bash(go generate:*)",
|
||||
"Bash(pnpm eslint:*)"
|
||||
"Bash(pnpm eslint:*)",
|
||||
"Read(//workspaces/cosy/settings/**)",
|
||||
"Bash(go doc:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -25,3 +25,4 @@ internal/**/*.gen.go
|
||||
log-index/
|
||||
*.prof
|
||||
*.test
|
||||
GeoLite2-City.mmdb
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package geolite
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/0xJacky/Nginx-UI/internal/geolite"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/uozi-tech/cosy/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusInfo = "info"
|
||||
StatusError = "error"
|
||||
StatusProgress = "progress"
|
||||
)
|
||||
|
||||
type DownloadProgressResp struct {
|
||||
Status string `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func DownloadGeoLiteDB(c *gin.Context) {
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// Upgrade HTTP to WebSocket
|
||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
sendMessage := func(status, message string, progress float64) {
|
||||
if err := ws.WriteJSON(DownloadProgressResp{
|
||||
Status: status,
|
||||
Progress: progress,
|
||||
Message: message,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send WebSocket message:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if database already exists
|
||||
if geolite.DBExists() {
|
||||
sendMessage(StatusInfo, "Database already exists, removing old version...", 0)
|
||||
// Optionally remove old database here if you want to force re-download
|
||||
}
|
||||
|
||||
sendMessage(StatusInfo, "Starting download...", 0)
|
||||
|
||||
// Download progress channel
|
||||
downloadProgressChan := make(chan float64, 100)
|
||||
downloadDone := make(chan error, 1)
|
||||
|
||||
// Start download in goroutine
|
||||
go func() {
|
||||
downloadDone <- geolite.DownloadGeoLiteDB(downloadProgressChan)
|
||||
}()
|
||||
|
||||
// Track download progress (0-50%)
|
||||
downloadComplete := false
|
||||
for !downloadComplete {
|
||||
select {
|
||||
case progress := <-downloadProgressChan:
|
||||
// Scale download progress to 0-50%
|
||||
scaledProgress := progress * 0.5
|
||||
sendMessage(StatusProgress, "Downloading GeoLite2 database...", scaledProgress)
|
||||
case err := <-downloadDone:
|
||||
if err != nil {
|
||||
sendMessage(StatusError, "Download failed: "+err.Error(), 0)
|
||||
return
|
||||
}
|
||||
downloadComplete = true
|
||||
sendMessage(StatusInfo, "Download complete", 50)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(StatusInfo, "Decompressing database...", 50)
|
||||
|
||||
// Decompress progress channel
|
||||
decompressProgressChan := make(chan float64, 100)
|
||||
decompressDone := make(chan error, 1)
|
||||
|
||||
// Start decompression in goroutine
|
||||
go func() {
|
||||
decompressDone <- geolite.DecompressGeoLiteDB(decompressProgressChan)
|
||||
}()
|
||||
|
||||
// Track decompression progress (50-100%)
|
||||
decompressComplete := false
|
||||
for !decompressComplete {
|
||||
select {
|
||||
case progress := <-decompressProgressChan:
|
||||
// Scale decompress progress to 50-100%
|
||||
scaledProgress := 50 + (progress * 0.5)
|
||||
sendMessage(StatusProgress, "Decompressing database...", scaledProgress)
|
||||
case err := <-decompressDone:
|
||||
if err != nil {
|
||||
sendMessage(StatusError, "Decompression failed: "+err.Error(), 50)
|
||||
return
|
||||
}
|
||||
decompressComplete = true
|
||||
sendMessage(StatusInfo, "Database ready", 100)
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(StatusInfo, "GeoLite2 database downloaded and installed successfully", 100)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package geolite
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/0xJacky/Nginx-UI/internal/geolite"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/uozi-tech/cosy"
|
||||
)
|
||||
|
||||
type StatusResp struct {
|
||||
Exists bool `json:"exists"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
LastModified string `json:"last_modified"`
|
||||
}
|
||||
|
||||
func GetStatus(c *gin.Context) {
|
||||
dbPath := geolite.GetDBPath()
|
||||
resp := StatusResp{
|
||||
Exists: geolite.DBExists(),
|
||||
Path: dbPath,
|
||||
}
|
||||
|
||||
if resp.Exists {
|
||||
fileInfo, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
cosy.ErrHandler(c, err)
|
||||
return
|
||||
}
|
||||
resp.Size = fileInfo.Size()
|
||||
resp.LastModified = fileInfo.ModTime().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
Vendored
+3
@@ -11,6 +11,7 @@ declare module 'vue' {
|
||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||
AApp: typeof import('ant-design-vue/es')['App']
|
||||
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
|
||||
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
|
||||
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
|
||||
@@ -69,6 +70,7 @@ declare module 'vue' {
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
ATypographyParagraph: typeof import('ant-design-vue/es')['TypographyParagraph']
|
||||
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
|
||||
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
|
||||
AutoCertFormAutoCertForm: typeof import('./src/components/AutoCertForm/AutoCertForm.vue')['default']
|
||||
@@ -84,6 +86,7 @@ declare module 'vue' {
|
||||
ConfigHistoryDiffViewer: typeof import('./src/components/ConfigHistory/DiffViewer.vue')['default']
|
||||
DevDebugPanelDevDebugPanel: typeof import('./src/components/DevDebugPanel/DevDebugPanel.vue')['default']
|
||||
FooterToolbarFooterToolBar: typeof import('./src/components/FooterToolbar/FooterToolBar.vue')['default']
|
||||
GeoLiteDownloadGeoLiteDownload: typeof import('./src/components/GeoLiteDownload/GeoLiteDownload.vue')['default']
|
||||
ICPICP: typeof import('./src/components/ICP/ICP.vue')['default']
|
||||
InspectConfigInspectConfig: typeof import('./src/components/InspectConfig/InspectConfig.vue')['default']
|
||||
LLMChatMessage: typeof import('./src/components/LLM/ChatMessage.vue')['default']
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { http } from '@uozi-admin/request'
|
||||
|
||||
export interface GeoLiteStatus {
|
||||
exists: boolean
|
||||
path: string
|
||||
size: number
|
||||
last_modified: string
|
||||
}
|
||||
|
||||
const geolite = {
|
||||
async getStatus() {
|
||||
return http.get<GeoLiteStatus>('geolite/status')
|
||||
},
|
||||
}
|
||||
|
||||
export default geolite
|
||||
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue'
|
||||
import { CheckCircleOutlined, DownloadOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
|
||||
import geolite from '@/api/geolite'
|
||||
import { formatDateTime } from '@/lib/helper'
|
||||
import websocket from '@/lib/websocket'
|
||||
|
||||
interface Emits {
|
||||
(e: 'downloadComplete'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// GeoLite database state
|
||||
const geoLiteStatus = ref({
|
||||
exists: false,
|
||||
path: '',
|
||||
size: 0,
|
||||
last_modified: '',
|
||||
})
|
||||
const geoLiteLoading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const downloadStatus = ref('active') as Ref<'normal' | 'active' | 'success' | 'exception'>
|
||||
const downloadMessage = ref('')
|
||||
|
||||
const progressStrokeColor = {
|
||||
from: '#108ee9',
|
||||
to: '#87d068',
|
||||
}
|
||||
|
||||
const downloadProgressComputed = computed(() => {
|
||||
return Number.parseFloat(downloadProgress.value.toFixed(1))
|
||||
})
|
||||
|
||||
// Check GeoLite database status
|
||||
async function checkGeoLiteStatus() {
|
||||
try {
|
||||
geoLiteLoading.value = true
|
||||
const status = await geolite.getStatus()
|
||||
geoLiteStatus.value = status
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to check GeoLite status:', e)
|
||||
}
|
||||
finally {
|
||||
geoLiteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Download GeoLite database
|
||||
function downloadGeoLiteDB() {
|
||||
downloading.value = true
|
||||
downloadStatus.value = 'active'
|
||||
downloadProgress.value = 0
|
||||
downloadMessage.value = $gettext('Starting download...')
|
||||
|
||||
const ws = websocket('/api/geolite/download', false)
|
||||
|
||||
let isFailed = false
|
||||
let currentPhase = 'download' // 'download' or 'decompress'
|
||||
|
||||
ws.onopen = () => {
|
||||
// WebSocket connected, server will start download
|
||||
}
|
||||
|
||||
ws.onmessage = async m => {
|
||||
const r = JSON.parse(m.data)
|
||||
|
||||
// Update message and detect phase changes
|
||||
if (r.message) {
|
||||
downloadMessage.value = r.message
|
||||
|
||||
// Detect phase transition
|
||||
if (r.message.toLowerCase().includes('decompress')) {
|
||||
currentPhase = 'decompress'
|
||||
}
|
||||
}
|
||||
|
||||
switch (r.status) {
|
||||
case 'info':
|
||||
// Info messages handled above
|
||||
break
|
||||
case 'progress': {
|
||||
// Map progress to correct range based on phase
|
||||
const actualProgress = currentPhase === 'download'
|
||||
? (r.progress / 100) * 50 // Download phase: 0-50%
|
||||
: 50 + (r.progress / 100) * 50 // Decompress phase: 50-100%
|
||||
|
||||
downloadProgress.value = Math.min(actualProgress, 100)
|
||||
break
|
||||
}
|
||||
case 'error':
|
||||
downloadStatus.value = 'exception'
|
||||
isFailed = true
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
isFailed = true
|
||||
downloadStatus.value = 'exception'
|
||||
downloadMessage.value = $gettext('Download failed')
|
||||
}
|
||||
|
||||
ws.onclose = async () => {
|
||||
if (isFailed) {
|
||||
downloading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
downloadStatus.value = 'success'
|
||||
downloadProgress.value = 100
|
||||
downloadMessage.value = $gettext('Download complete')
|
||||
|
||||
// Refresh status
|
||||
await checkGeoLiteStatus()
|
||||
|
||||
// Emit completion event
|
||||
emit('downloadComplete')
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
downloading.value = false
|
||||
downloadProgress.value = 0
|
||||
downloadMessage.value = ''
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check status on mount
|
||||
onMounted(() => {
|
||||
checkGeoLiteStatus()
|
||||
})
|
||||
|
||||
// Expose methods for parent components
|
||||
defineExpose({
|
||||
checkGeoLiteStatus,
|
||||
downloadGeoLiteDB,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AAlert
|
||||
v-if="!geoLiteStatus.exists && !downloading"
|
||||
:message="$gettext('GeoLite2 Database Required')"
|
||||
type="info"
|
||||
show-icon
|
||||
:icon="h(InfoCircleOutlined)"
|
||||
class="mb-3"
|
||||
>
|
||||
<template #description>
|
||||
<div class="space-y-2">
|
||||
<p>{{ $gettext('The GeoLite2 database is required for offline geographic IP analysis. Please download it to enable this feature.') }}</p>
|
||||
<p class="text-sm">
|
||||
{{ $gettext('Alternatively, if you cannot download the database, you can manually place') }}
|
||||
<code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded">GeoLite2-City.mmdb</code>
|
||||
{{ $gettext('in the same directory as') }}
|
||||
<code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded">app.ini</code>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</AAlert>
|
||||
|
||||
<AAlert
|
||||
v-else-if="geoLiteStatus.exists && !downloading"
|
||||
:message="$gettext('GeoLite2 Database Installed')"
|
||||
type="success"
|
||||
show-icon
|
||||
:icon="h(CheckCircleOutlined)"
|
||||
class="mb-3"
|
||||
banner
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Download Button -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<AButton
|
||||
v-if="!geoLiteStatus.exists"
|
||||
type="primary"
|
||||
:loading="geoLiteLoading"
|
||||
:disabled="downloading"
|
||||
@click="downloadGeoLiteDB"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
{{ $gettext('Download GeoLite2 Database') }}
|
||||
</AButton>
|
||||
<AButton
|
||||
v-else
|
||||
:loading="geoLiteLoading"
|
||||
:disabled="downloading"
|
||||
@click="downloadGeoLiteDB"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
{{ $gettext('Re-download Database') }}
|
||||
</AButton>
|
||||
<ATypographyText v-if="geoLiteStatus.exists && !downloading" type="secondary" class="text-xs">
|
||||
{{ $gettext('Last updated:') }} {{ formatDateTime(geoLiteStatus.last_modified) }}
|
||||
</ATypographyText>
|
||||
</div>
|
||||
|
||||
<!-- Inline Progress Bar -->
|
||||
<div v-if="downloading" class="download-progress-section">
|
||||
<AProgress
|
||||
:stroke-color="progressStrokeColor"
|
||||
:percent="downloadProgressComputed"
|
||||
:status="downloadStatus"
|
||||
/>
|
||||
<ATypographyText v-if="downloadMessage" type="secondary" class="text-sm mt-2">
|
||||
{{ downloadMessage }}
|
||||
</ATypographyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './GeoLiteDownload.vue'
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { NginxPerformanceInfo } from '@/api/ngx'
|
||||
import ngx from '@/api/ngx'
|
||||
import { formatDateTime } from '@/lib/helper'
|
||||
|
||||
export function useNginxPerformance() {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const nginxInfo = ref<NginxPerformanceInfo | null>(null)
|
||||
const lastUpdateTime = ref<Date | null>(null)
|
||||
const lastUpdateTime = ref<string>('')
|
||||
|
||||
// stub_status availability
|
||||
const stubStatusEnabled = ref(false)
|
||||
@@ -16,12 +17,12 @@ export function useNginxPerformance() {
|
||||
const formattedUpdateTime = computed(() => {
|
||||
if (!lastUpdateTime.value)
|
||||
return $gettext('Unknown')
|
||||
return lastUpdateTime.value.toLocaleString()
|
||||
return formatDateTime(lastUpdateTime.value)
|
||||
})
|
||||
|
||||
// Update the last update time
|
||||
function updateLastUpdateTime() {
|
||||
lastUpdateTime.value = new Date()
|
||||
lastUpdateTime.value = new Date().toISOString()
|
||||
}
|
||||
|
||||
// Check stub_status availability and get initial data
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="tsx">
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, HeartOutlined, InfoCircleOutlined, MailOutlined, ThunderboltOutlined, WarningOutlined } from '@ant-design/icons-vue'
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, DownloadOutlined, HeartOutlined, InfoCircleOutlined, MailOutlined, ThunderboltOutlined, WarningOutlined } from '@ant-design/icons-vue'
|
||||
import geolite from '@/api/geolite'
|
||||
import GeoLiteDownload from '@/components/GeoLiteDownload'
|
||||
|
||||
interface Props {
|
||||
loading?: boolean
|
||||
@@ -18,6 +20,29 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = defineModel<boolean>('visible', { required: true })
|
||||
|
||||
// GeoLite database status
|
||||
const geoLiteDownloadRef = ref<InstanceType<typeof GeoLiteDownload>>()
|
||||
const geoLiteExists = ref(false)
|
||||
|
||||
// Check GeoLite status when modal opens
|
||||
watch(visible, async newVal => {
|
||||
if (newVal) {
|
||||
try {
|
||||
const status = await geolite.getStatus()
|
||||
geoLiteExists.value = status.exists
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to check GeoLite status:', e)
|
||||
geoLiteExists.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update GeoLite status when download completes
|
||||
function onGeoLiteDownloadComplete() {
|
||||
geoLiteExists.value = true
|
||||
}
|
||||
|
||||
const systemRequirements = [
|
||||
{
|
||||
title: $gettext('CPU'),
|
||||
@@ -73,6 +98,7 @@ function handleCancel() {
|
||||
:title="$gettext('Enable Advanced Log Indexing')"
|
||||
:confirm-loading="loading"
|
||||
:ok-text="$gettext('Enable Indexing')"
|
||||
:ok-button-props="{ disabled: !geoLiteExists }"
|
||||
:cancel-text="$gettext('Cancel')"
|
||||
width="720px"
|
||||
@ok="handleConfirm"
|
||||
@@ -146,6 +172,21 @@ function handleCancel() {
|
||||
|
||||
<ADivider />
|
||||
|
||||
<!-- GeoLite Database Section -->
|
||||
<div>
|
||||
<ATypographyTitle :level="4" class="mb-3">
|
||||
<DownloadOutlined class="mr-2 text-purple-500" />
|
||||
{{ $gettext('GeoLite2 Database') }}
|
||||
</ATypographyTitle>
|
||||
|
||||
<GeoLiteDownload
|
||||
ref="geoLiteDownloadRef"
|
||||
@download-complete="onGeoLiteDownloadComplete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ADivider />
|
||||
|
||||
<!-- Performance Statistics -->
|
||||
<div>
|
||||
<ATypographyTitle :level="4" class="mb-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AuthSettings,
|
||||
CertSettings,
|
||||
ExternalNotify,
|
||||
GeoLiteSettings,
|
||||
HTTPSettings,
|
||||
LogrotateSettings,
|
||||
NginxSettings,
|
||||
@@ -107,9 +108,15 @@ onMounted(() => {
|
||||
>
|
||||
<LogrotateSettings />
|
||||
</ATabPane>
|
||||
<ATabPane
|
||||
key="geolite"
|
||||
:tab="$gettext('GeoLite')"
|
||||
>
|
||||
<GeoLiteSettings />
|
||||
</ATabPane>
|
||||
</ATabs>
|
||||
</div>
|
||||
<FooterToolBar v-if="activeKey !== 'external_notify'">
|
||||
<FooterToolBar v-if="activeKey !== 'external_notify' && activeKey !== 'geolite'">
|
||||
<AButton
|
||||
type="primary"
|
||||
@click="systemSettingsStore.save"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import GeoLiteDownload from '@/components/GeoLiteDownload'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AForm layout="vertical">
|
||||
<AFormItem :label="$gettext('GeoLite2 Database')">
|
||||
<ATypographyParagraph type="secondary">
|
||||
{{ $gettext('The GeoLite2 database provides geographic information for IP addresses. This is used for offline geographic analysis in log analytics.') }}
|
||||
</ATypographyParagraph>
|
||||
<GeoLiteDownload />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@ export { default as AppSettings } from './AppSettings.vue'
|
||||
export { default as AuthSettings } from './AuthSettings.vue'
|
||||
export { default as CertSettings } from './CertSettings.vue'
|
||||
export { default as ExternalNotify } from './ExternalNotify.vue'
|
||||
export { default as GeoLiteSettings } from './GeoLiteSettings.vue'
|
||||
export { default as HTTPSettings } from './HTTPSettings.vue'
|
||||
export { default as LogrotateSettings } from './LogrotateSettings.vue'
|
||||
export { default as NginxSettings } from './NginxSettings.vue'
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,197 @@
|
||||
package geolite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
"github.com/uozi-tech/cosy"
|
||||
"github.com/uozi-tech/cosy/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
DownloadURL = "http://cloud.nginxui.com/geolite/GeoLite2-City.mmdb.xz"
|
||||
)
|
||||
|
||||
type DownloadProgressWriter struct {
|
||||
io.Writer
|
||||
totalSize int64
|
||||
currentSize int64
|
||||
progressChan chan<- float64
|
||||
lastReported float64
|
||||
reportInterval float64 // Report only when progress changes by this amount
|
||||
}
|
||||
|
||||
func (pw *DownloadProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.Writer.Write(p)
|
||||
pw.currentSize += int64(n)
|
||||
progress := float64(pw.currentSize) / float64(pw.totalSize) * 100
|
||||
|
||||
// Debounce: only send updates when progress changes by reportInterval or reaches 100%
|
||||
if progress-pw.lastReported >= pw.reportInterval || progress >= 100 {
|
||||
select {
|
||||
case pw.progressChan <- progress:
|
||||
pw.lastReported = progress
|
||||
default:
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// GetDBPath returns the path to the GeoLite2 database file
|
||||
func GetDBPath() string {
|
||||
confDir := filepath.Dir(settings.ConfPath)
|
||||
return filepath.Join(confDir, "GeoLite2-City.mmdb")
|
||||
}
|
||||
|
||||
// GetDBXZPath returns the path to the compressed GeoLite2 database file
|
||||
func GetDBXZPath() string {
|
||||
confDir := filepath.Dir(settings.ConfPath)
|
||||
return filepath.Join(confDir, "GeoLite2-City.mmdb.xz")
|
||||
}
|
||||
|
||||
// DownloadGeoLiteDB downloads the GeoLite2 database
|
||||
func DownloadGeoLiteDB(progressChan chan float64) error {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", DownloadURL, nil)
|
||||
if err != nil {
|
||||
return cosy.WrapErrorWithParams(ErrDownloadFailed, err.Error())
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return cosy.WrapErrorWithParams(ErrDownloadFailed, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return cosy.WrapErrorWithParams(ErrDownloadFailed, fmt.Sprintf("status code: %d", resp.StatusCode))
|
||||
}
|
||||
|
||||
totalSize, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
|
||||
if err != nil {
|
||||
return cosy.WrapErrorWithParams(ErrFailedToGetFileSize, err.Error())
|
||||
}
|
||||
|
||||
xzPath := GetDBXZPath()
|
||||
file, err := os.Create(xzPath)
|
||||
if err != nil {
|
||||
return cosy.WrapErrorWithParams(ErrFailedToCreateFile, err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
progressWriter := &DownloadProgressWriter{
|
||||
Writer: file,
|
||||
totalSize: totalSize,
|
||||
progressChan: progressChan,
|
||||
reportInterval: 1.0, // Report every 1% change
|
||||
}
|
||||
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
if err != nil {
|
||||
os.Remove(xzPath) // Clean up on error
|
||||
return cosy.WrapErrorWithParams(ErrFailedToSaveFile, err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecompressGeoLiteDB decompresses the .xz file to .mmdb
|
||||
func DecompressGeoLiteDB(progressChan chan float64) error {
|
||||
xzPath := GetDBXZPath()
|
||||
dbPath := GetDBPath()
|
||||
|
||||
// Open compressed file
|
||||
xzFile, err := os.Open(xzPath)
|
||||
if err != nil {
|
||||
return cosy.WrapErrorWithParams(ErrFailedToOpenFile, err.Error())
|
||||
}
|
||||
defer xzFile.Close()
|
||||
|
||||
// Get compressed file size
|
||||
fileInfo, err := xzFile.Stat()
|
||||
if err != nil {
|
||||
return cosy.WrapErrorWithParams(ErrFailedToGetFileSize, err.Error())
|
||||
}
|
||||
compressedSize := fileInfo.Size()
|
||||
|
||||
// Create XZ reader
|
||||
xzReader, err := xz.NewReader(xzFile)
|
||||
if err != nil {
|
||||
return cosy.WrapErrorWithParams(ErrFailedToCreateXZReader, err.Error())
|
||||
}
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(dbPath)
|
||||
if err != nil {
|
||||
return cosy.WrapErrorWithParams(ErrFailedToCreateFile, err.Error())
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Decompress with progress tracking
|
||||
buf := make([]byte, 64*1024) // 64KB buffer for better performance
|
||||
var decompressedSize int64
|
||||
var lastReportedProgress float64
|
||||
const reportInterval = 2.0 // Report every 2% change
|
||||
|
||||
// Estimate: XZ typically compresses to 10-20% of original size
|
||||
// We'll use 15% (compression ratio ~6.67) as middle estimate
|
||||
const estimatedCompressionRatio = 6.67
|
||||
estimatedTotalSize := float64(compressedSize) * estimatedCompressionRatio
|
||||
|
||||
for {
|
||||
n, readErr := xzReader.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := outFile.Write(buf[:n]); writeErr != nil {
|
||||
os.Remove(dbPath) // Clean up on error
|
||||
return cosy.WrapErrorWithParams(ErrFailedToWriteData, writeErr.Error())
|
||||
}
|
||||
decompressedSize += int64(n)
|
||||
|
||||
// Calculate progress based on estimated total size
|
||||
progress := (float64(decompressedSize) / estimatedTotalSize) * 100
|
||||
if progress > 99 {
|
||||
progress = 99 // Cap at 99% until actually complete
|
||||
}
|
||||
|
||||
// Debounce: only send updates when progress changes significantly
|
||||
if progress-lastReportedProgress >= reportInterval || readErr == io.EOF {
|
||||
select {
|
||||
case progressChan <- progress:
|
||||
lastReportedProgress = progress
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
// Send 100% on completion
|
||||
select {
|
||||
case progressChan <- 100:
|
||||
default:
|
||||
}
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
os.Remove(dbPath) // Clean up on error
|
||||
return cosy.WrapErrorWithParams(ErrFailedToReadData, readErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the .xz file after successful decompression
|
||||
if err := os.Remove(xzPath); err != nil {
|
||||
// Log but don't fail if we can't delete the compressed file
|
||||
return cosy.WrapErrorWithParams(ErrFailedToDeleteCompressed, err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DBExists checks if the GeoLite2 database file exists
|
||||
func DBExists() bool {
|
||||
_, err := os.Stat(GetDBPath())
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package geolite
|
||||
|
||||
import "github.com/uozi-tech/cosy"
|
||||
|
||||
var (
|
||||
e = cosy.NewErrorScope("geolite")
|
||||
ErrDownloadFailed = e.New(60000, "failed to download GeoLite2 database: {0}")
|
||||
ErrDecompressionFailed = e.New(60001, "failed to decompress GeoLite2 database: {0}")
|
||||
ErrDatabaseNotFound = e.New(60002, "GeoLite2 database not found at {0}")
|
||||
ErrFailedToGetFileSize = e.New(60003, "failed to get file size: {0}")
|
||||
ErrFailedToCreateFile = e.New(60004, "failed to create file: {0}")
|
||||
ErrFailedToSaveFile = e.New(60005, "failed to save downloaded file: {0}")
|
||||
ErrFailedToOpenFile = e.New(60006, "failed to open file: {0}")
|
||||
ErrFailedToCreateXZReader = e.New(60007, "failed to create xz reader: {0}")
|
||||
ErrFailedToWriteData = e.New(60008, "failed to write decompressed data: {0}")
|
||||
ErrFailedToReadData = e.New(60009, "failed to read compressed data: {0}")
|
||||
ErrFailedToDeleteCompressed = e.New(60010, "decompression succeeded but failed to delete compressed file: {0}")
|
||||
)
|
||||
+15
-30
@@ -1,21 +1,14 @@
|
||||
package geolite
|
||||
package geolite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
"github.com/ulikunitz/xz"
|
||||
"github.com/uozi-tech/cosy/geoip"
|
||||
)
|
||||
|
||||
//go:embed GeoLite2-City.mmdb.xz
|
||||
var embeddedCityDB []byte
|
||||
|
||||
type IPLocation struct {
|
||||
RegionCode string `json:"region_code"`
|
||||
Province string `json:"province"`
|
||||
@@ -41,33 +34,25 @@ func GetService() (*Service, error) {
|
||||
}
|
||||
|
||||
func (s *Service) init() error {
|
||||
// Load embedded compressed database
|
||||
if len(embeddedCityDB) > 0 {
|
||||
if err := s.loadEmbeddedCityDB(); err != nil {
|
||||
return fmt.Errorf("failed to load embedded city database: %v", err)
|
||||
}
|
||||
return nil
|
||||
// Load database from file (memory-mapped for efficiency)
|
||||
dbPath := GetDBPath()
|
||||
|
||||
if !DBExists() {
|
||||
return fmt.Errorf("GeoLite2 database not found at %s. Please download it first", dbPath)
|
||||
}
|
||||
|
||||
return fmt.Errorf("no embedded GeoLite2 City database available")
|
||||
if err := s.loadFromFile(dbPath); err != nil {
|
||||
return fmt.Errorf("failed to load GeoLite2 database: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) loadEmbeddedCityDB() error {
|
||||
// Decompress the embedded database
|
||||
xzReader, err := xz.NewReader(bytes.NewReader(embeddedCityDB))
|
||||
func (s *Service) loadFromFile(path string) error {
|
||||
// Open database file with memory mapping (more efficient than loading into memory)
|
||||
cityDB, err := geoip2.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create xz reader: %v", err)
|
||||
}
|
||||
|
||||
decompressedData, err := io.ReadAll(xzReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decompress database: %v", err)
|
||||
}
|
||||
|
||||
// Create geoip2 reader from decompressed data
|
||||
cityDB, err := geoip2.FromBytes(decompressedData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create geoip2 reader: %v", err)
|
||||
return fmt.Errorf("failed to open GeoLite2 database: %v", err)
|
||||
}
|
||||
|
||||
s.cityDB = cityDB
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/0xJacky/Nginx-UI/api/crypto"
|
||||
"github.com/0xJacky/Nginx-UI/api/event"
|
||||
"github.com/0xJacky/Nginx-UI/api/external_notify"
|
||||
"github.com/0xJacky/Nginx-UI/api/geolite"
|
||||
"github.com/0xJacky/Nginx-UI/api/license"
|
||||
"github.com/0xJacky/Nginx-UI/api/llm"
|
||||
"github.com/0xJacky/Nginx-UI/api/nginx"
|
||||
@@ -96,6 +97,7 @@ func InitRouter() {
|
||||
external_notify.InitRouter(g)
|
||||
backup.InitAutoBackupRouter(g)
|
||||
nginxLog.InitRouter(g)
|
||||
g.GET("/geolite/status", geolite.GetStatus)
|
||||
}
|
||||
|
||||
// Authorization required and websocket request
|
||||
@@ -113,6 +115,7 @@ func InitRouter() {
|
||||
system.InitWebSocketRouter(w)
|
||||
nginx.InitWebSocketRouter(w)
|
||||
cluster.InitWebSocketRouter(w)
|
||||
w.GET("/geolite/download", geolite.DownloadGeoLiteDB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user