From 0f44c16f43085c8df09d95f09a14486bc96f5f07 Mon Sep 17 00:00:00 2001 From: Pushkinmazila2 Date: Mon, 20 Apr 2026 23:11:10 +0300 Subject: [PATCH] add git 1 eter --- api/apiHandler.go | 14 +- api/apiService.go | 37 ++ cronjob/cronJob.go | 4 +- cronjob/gitSyncJob.go | 40 ++ database/backup.go | 17 +- database/db.go | 3 +- database/model/gitsync.go | 15 + docs/GIT_SYNC_API_EXAMPLES.md | 269 ++++++++++++++ docs/GIT_SYNC_EN.md | 175 +++++++++ docs/GIT_SYNC_RU.md | 176 +++++++++ service/gitsync.go | 679 ++++++++++++++++++++++++++++++++++ 11 files changed, 1422 insertions(+), 7 deletions(-) create mode 100644 cronjob/gitSyncJob.go create mode 100644 database/model/gitsync.go create mode 100644 docs/GIT_SYNC_API_EXAMPLES.md create mode 100644 docs/GIT_SYNC_EN.md create mode 100644 docs/GIT_SYNC_RU.md create mode 100644 service/gitsync.go diff --git a/api/apiHandler.go b/api/apiHandler.go index 5c73cd1..4138571 100644 --- a/api/apiHandler.go +++ b/api/apiHandler.go @@ -50,8 +50,16 @@ func (a *APIHandler) postHandler(c *gin.Context) { a.ApiService.LinkConvert(c) case "subConvert": a.ApiService.SubConvert(c) - case "importdb": + case "importdb": a.ApiService.ImportDb(c) + case "gitSyncConfig": + a.ApiService.SaveGitSyncConfig(c) + case "gitSyncPush": + a.ApiService.GitSyncPush(c) + case "gitSyncPull": + a.ApiService.GitSyncPull(c) + case "gitSyncTest": + a.ApiService.GitSyncTest(c) case "addToken": a.ApiService.AddToken(c) a.apiv2.ReloadTokens() @@ -99,8 +107,10 @@ func (a *APIHandler) getHandler(c *gin.Context) { a.ApiService.GetTokens(c) case "singbox-config": a.ApiService.GetSingboxConfig(c) - case "checkOutbound": + case "checkOutbound": a.ApiService.GetCheckOutbound(c) + case "gitSyncConfig": + a.ApiService.GetGitSyncConfig(c) default: jsonMsg(c, "failed", common.NewError("unknown action: ", action)) } diff --git a/api/apiService.go b/api/apiService.go index 9ab0212..e35c3b6 100644 --- a/api/apiService.go +++ b/api/apiService.go @@ -26,6 +26,7 @@ type ApiService struct { service.PanelService service.StatsService service.ServerService + service.GitSyncService } func (a *ApiService) LoadData(c *gin.Context) { @@ -403,3 +404,39 @@ func (a *ApiService) GetCheckOutbound(c *gin.Context) { result := a.ConfigService.CheckOutbound(tag, link) jsonObj(c, result, nil) } + +func (a *ApiService) GetGitSyncConfig(c *gin.Context) { + config, err := a.GitSyncService.GetConfig() + if err != nil { + jsonMsg(c, "", err) + return + } + config.Token = "***" // Hide token in response + jsonObj(c, config, nil) +} + +func (a *ApiService) SaveGitSyncConfig(c *gin.Context) { + var config database.model.GitSync + err := c.ShouldBindJSON(&config) + if err != nil { + jsonMsg(c, "", err) + return + } + err = a.GitSyncService.SaveConfig(&config) + jsonMsg(c, "", err) +} + +func (a *ApiService) GitSyncPush(c *gin.Context) { + err := a.GitSyncService.PushToGit() + jsonMsg(c, "", err) +} + +func (a *ApiService) GitSyncPull(c *gin.Context) { + err := a.GitSyncService.PullFromGit() + jsonMsg(c, "", err) +} + +func (a *ApiService) GitSyncTest(c *gin.Context) { + err := a.GitSyncService.TestConnection() + jsonMsg(c, "", err) +} diff --git a/cronjob/cronJob.go b/cronjob/cronJob.go index 6f1f24b..627879f 100644 --- a/cronjob/cronJob.go +++ b/cronjob/cronJob.go @@ -18,7 +18,7 @@ func (c *CronJob) Start(loc *time.Location, trafficAge int) error { c.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds()) c.cron.Start() - go func() { + go func() { // Start stats job c.cron.AddJob("@every 10s", NewStatsJob(trafficAge > 0)) // Start expiry job @@ -31,6 +31,8 @@ func (c *CronJob) Start(loc *time.Location, trafficAge int) error { c.cron.AddJob("@every 5s", NewCheckCoreJob()) // database WAL checkpoint c.cron.AddJob("@every 10m", NewWALCheckpointJob()) + // Git sync job + c.cron.AddJob("@every 1h", NewGitSyncJob()) }() return nil diff --git a/cronjob/gitSyncJob.go b/cronjob/gitSyncJob.go new file mode 100644 index 0000000..9b65a7e --- /dev/null +++ b/cronjob/gitSyncJob.go @@ -0,0 +1,40 @@ +package cronjob + +import ( + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/service" +) + +type GitSyncJob struct { + gitSyncService service.GitSyncService +} + +func NewGitSyncJob() *GitSyncJob { + return &GitSyncJob{} +} + +func (j *GitSyncJob) Run() { + config, err := j.gitSyncService.GetConfig() + if err != nil { + db := database.GetDB() + var count int64 + db.Model(&model.GitSync{}).Count(&count) + if count == 0 { + return + } + logger.Debug("Git sync job: failed to get config:", err) + return + } + + if !config.Enable || !config.AutoSync { + return + } + + logger.Debug("Running Git sync job") + err = j.gitSyncService.PushToGit() + if err != nil { + logger.Error("Git sync job failed:", err) + } +} \ No newline at end of file diff --git a/database/backup.go b/database/backup.go index 69bb43d..9e70eab 100644 --- a/database/backup.go +++ b/database/backup.go @@ -44,7 +44,7 @@ func GetDb(exclude string) ([]byte, error) { } defer os.Remove(dbPath) - err = backupDb.AutoMigrate( + err = backupDb.AutoMigrate( &model.Setting{}, &model.Tls{}, &model.Inbound{}, @@ -54,12 +54,13 @@ func GetDb(exclude string) ([]byte, error) { &model.Stats{}, &model.Client{}, &model.Changes{}, + &model.GitSync{}, ) if err != nil { return nil, err } - var settings []model.Setting + var settings []model.Setting var tls []model.Tls var inbound []model.Inbound var outbound []model.Outbound @@ -68,6 +69,7 @@ func GetDb(exclude string) ([]byte, error) { var clients []model.Client var stats []model.Stats var changes []model.Changes + var gitSync []model.GitSync // Perform scans and handle errors if err := db.Model(&model.Setting{}).Scan(&settings).Error; err != nil { @@ -130,7 +132,7 @@ func GetDb(exclude string) ([]byte, error) { } } } - if !exclude_changes { + if !exclude_changes { if err := db.Model(&model.Changes{}).Scan(&changes).Error; err != nil { return nil, err } @@ -141,6 +143,15 @@ func GetDb(exclude string) ([]byte, error) { } } + if err := db.Model(&model.GitSync{}).Scan(&gitSync).Error; err != nil { + return nil, err + } + if len(gitSync) > 0 { + if err := backupDb.Save(gitSync).Error; err != nil { + return nil, err + } + } + // Update WAL err = backupDb.Exec("PRAGMA wal_checkpoint;").Error if err != nil { diff --git a/database/db.go b/database/db.go index 9d0a7ab..c1656f4 100644 --- a/database/db.go +++ b/database/db.go @@ -128,7 +128,7 @@ func InitDB(dbPath string) error { db.Create(&defaultOutbound) } - err = db.AutoMigrate( + err = db.AutoMigrate( &model.Setting{}, &model.Tls{}, &model.Inbound{}, @@ -140,6 +140,7 @@ func InitDB(dbPath string) error { &model.Stats{}, &model.Client{}, &model.Changes{}, + &model.GitSync{}, ) if err != nil { return err diff --git a/database/model/gitsync.go b/database/model/gitsync.go new file mode 100644 index 0000000..7d54694 --- /dev/null +++ b/database/model/gitsync.go @@ -0,0 +1,15 @@ +package model + +type GitSync struct { + Id uint `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Enable bool `json:"enable" form:"enable" gorm:"default:false;not null"` + Provider string `json:"provider" form:"provider"` // github, gitlab, gitea + RepoUrl string `json:"repoUrl" form:"repoUrl"` + Branch string `json:"branch" form:"branch" gorm:"default:main"` + Token string `json:"token" form:"token"` + AutoSync bool `json:"autoSync" form:"autoSync" gorm:"default:false;not null"` + SyncInterval int `json:"syncInterval" form:"syncInterval" gorm:"default:3600"` // seconds + LastSync int64 `json:"lastSync" form:"lastSync" gorm:"default:0"` + SyncConfig bool `json:"syncConfig" form:"syncConfig" gorm:"default:true;not null"` + SyncDb bool `json:"syncDb" form:"syncDb" gorm:"default:true;not null"` +} \ No newline at end of file diff --git a/docs/GIT_SYNC_API_EXAMPLES.md b/docs/GIT_SYNC_API_EXAMPLES.md new file mode 100644 index 0000000..926181e --- /dev/null +++ b/docs/GIT_SYNC_API_EXAMPLES.md @@ -0,0 +1,269 @@ +# Git Sync API Examples + +## Configuration Examples + +### GitHub Configuration + +```bash +curl -X POST http://localhost:2095/app/api/gitSyncConfig \ + -H "Content-Type: application/json" \ + -H "Cookie: session=your_session_cookie" \ + -d '{ + "enable": true, + "provider": "github", + "repoUrl": "https://github.com/username/s-ui-backup", + "branch": "main", + "token": "ghp_xxxxxxxxxxxxxxxxxxxx", + "autoSync": true, + "syncInterval": 3600, + "syncConfig": true, + "syncDb": true + }' +``` + +### GitLab Configuration + +```bash +curl -X POST http://localhost:2095/app/api/gitSyncConfig \ + -H "Content-Type: application/json" \ + -H "Cookie: session=your_session_cookie" \ + -d '{ + "enable": true, + "provider": "gitlab", + "repoUrl": "https://gitlab.com/username/s-ui-backup", + "branch": "main", + "token": "glpat-xxxxxxxxxxxxxxxxxxxx", + "autoSync": true, + "syncInterval": 3600, + "syncConfig": true, + "syncDb": true + }' +``` + +### Gitea Configuration + +```bash +curl -X POST http://localhost:2095/app/api/gitSyncConfig \ + -H "Content-Type: application/json" \ + -H "Cookie: session=your_session_cookie" \ + -d '{ + "enable": true, + "provider": "gitea", + "repoUrl": "https://gitea.example.com/username/s-ui-backup", + "branch": "main", + "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "autoSync": true, + "syncInterval": 3600, + "syncConfig": true, + "syncDb": true + }' +``` + +## Get Current Configuration + +```bash +curl -X GET http://localhost:2095/app/api/gitSyncConfig \ + -H "Cookie: session=your_session_cookie" +``` + +Response: +```json +{ + "success": true, + "obj": { + "id": 1, + "enable": true, + "provider": "github", + "repoUrl": "https://github.com/username/s-ui-backup", + "branch": "main", + "token": "***", + "autoSync": true, + "syncInterval": 3600, + "syncConfig": true, + "syncDb": true, + "lastSync": 1713643744 + } +} +``` + +## Manual Push to Git + +```bash +curl -X POST http://localhost:2095/app/api/gitSyncPush \ + -H "Cookie: session=your_session_cookie" +``` + +Response: +```json +{ + "success": true, + "msg": "" +} +``` + +## Manual Pull from Git + +```bash +curl -X POST http://localhost:2095/app/api/gitSyncPull \ + -H "Cookie: session=your_session_cookie" +``` + +Response: +```json +{ + "success": true, + "msg": "" +} +``` + +## Test Connection + +```bash +curl -X POST http://localhost:2095/app/api/gitSyncTest \ + -H "Cookie: session=your_session_cookie" +``` + +Success Response: +```json +{ + "success": true, + "msg": "" +} +``` + +Error Response: +```json +{ + "success": false, + "msg": "GitHub API error: 401 - Bad credentials" +} +``` + +## Disable Sync + +```bash +curl -X POST http://localhost:2095/app/api/gitSyncConfig \ + -H "Content-Type: application/json" \ + -H "Cookie: session=your_session_cookie" \ + -d '{ + "enable": false, + "provider": "github", + "repoUrl": "https://github.com/username/s-ui-backup", + "branch": "main", + "token": "ghp_xxxxxxxxxxxxxxxxxxxx", + "autoSync": false, + "syncInterval": 3600, + "syncConfig": true, + "syncDb": true + }' +``` + +## JavaScript/Fetch Examples + +### Configure Git Sync + +```javascript +fetch('/app/api/gitSyncConfig', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + enable: true, + provider: 'github', + repoUrl: 'https://github.com/username/s-ui-backup', + branch: 'main', + token: 'ghp_xxxxxxxxxxxxxxxxxxxx', + autoSync: true, + syncInterval: 3600, + syncConfig: true, + syncDb: true + }) +}) +.then(response => response.json()) +.then(data => console.log(data)); +``` + +### Push to Git + +```javascript +fetch('/app/api/gitSyncPush', { + method: 'POST' +}) +.then(response => response.json()) +.then(data => { + if (data.success) { + console.log('Successfully pushed to Git'); + } else { + console.error('Push failed:', data.msg); + } +}); +``` + +### Test Connection + +```javascript +fetch('/app/api/gitSyncTest', { + method: 'POST' +}) +.then(response => response.json()) +.then(data => { + if (data.success) { + console.log('Connection successful'); + } else { + console.error('Connection failed:', data.msg); + } +}); +``` + +## Python Examples + +### Configure Git Sync + +```python +import requests + +url = 'http://localhost:2095/app/api/gitSyncConfig' +headers = {'Content-Type': 'application/json'} +cookies = {'session': 'your_session_cookie'} + +data = { + 'enable': True, + 'provider': 'github', + 'repoUrl': 'https://github.com/username/s-ui-backup', + 'branch': 'main', + 'token': 'ghp_xxxxxxxxxxxxxxxxxxxx', + 'autoSync': True, + 'syncInterval': 3600, + 'syncConfig': True, + 'syncDb': True +} + +response = requests.post(url, json=data, headers=headers, cookies=cookies) +print(response.json()) +``` + +### Push to Git + +```python +import requests + +url = 'http://localhost:2095/app/api/gitSyncPush' +cookies = {'session': 'your_session_cookie'} + +response = requests.post(url, cookies=cookies) +result = response.json() + +if result['success']: + print('Successfully pushed to Git') +else: + print(f'Push failed: {result["msg"]}') +``` + +## Notes + +- Replace `localhost:2095` with your actual S-UI server address +- Replace `your_session_cookie` with your actual session cookie +- Replace tokens with your actual access tokens +- All POST requests require authentication via session cookie +- Tokens are masked (shown as `***`) in GET responses for security diff --git a/docs/GIT_SYNC_EN.md b/docs/GIT_SYNC_EN.md new file mode 100644 index 0000000..c8aa7b8 --- /dev/null +++ b/docs/GIT_SYNC_EN.md @@ -0,0 +1,175 @@ +# Git Synchronization + +## Overview + +Git synchronization feature allows automatic backup and restore of SingBox configuration and database to Git repositories (GitHub, GitLab, Gitea). + +## Supported Providers + +- **GitHub** - https://github.com +- **GitLab** - https://gitlab.com or self-hosted +- **Gitea** - self-hosted + +## Setup + +### 1. Create Access Token + +#### GitHub +1. Go to Settings → Developer settings → Personal access tokens → Tokens (classic) +2. Create new token with `repo` scope (Full control of private repositories) +3. Copy the token + +#### GitLab +1. Go to Settings → Access Tokens +2. Create token with `api`, `read_repository`, `write_repository` scopes +3. Copy the token + +#### Gitea +1. Go to Settings → Applications → Generate New Token +2. Select `repo` scope (Full control of repositories) +3. Copy the token + +### 2. Create Repository + +Create a new private repository for storing configurations and backups. + +### 3. Configure in S-UI + +#### API Endpoints + +**Get configuration:** +``` +GET /app/api/gitSyncConfig +``` + +**Save configuration:** +``` +POST /app/api/gitSyncConfig +Content-Type: application/json + +{ + "enable": true, + "provider": "github", + "repoUrl": "https://github.com/username/repo", + "branch": "main", + "token": "your_token_here", + "autoSync": true, + "syncInterval": 3600, + "syncConfig": true, + "syncDb": true +} +``` + +**Parameters:** +- `enable` - enable/disable synchronization +- `provider` - provider: `github`, `gitlab`, `gitea` +- `repoUrl` - repository URL +- `branch` - branch name (default `main`) +- `token` - access token +- `autoSync` - automatic synchronization +- `syncInterval` - sync interval in seconds (default 3600 = 1 hour) +- `syncConfig` - sync SingBox configuration +- `syncDb` - sync database + +**Push to Git:** +``` +POST /app/api/gitSyncPush +``` + +**Pull from Git:** +``` +POST /app/api/gitSyncPull +``` + +**Test connection:** +``` +POST /app/api/gitSyncTest +``` + +## Usage + +### Manual Synchronization + +1. Configure Git sync settings via API +2. Call `/app/api/gitSyncPush` to push data to Git +3. Call `/app/api/gitSyncPull` to pull data from Git + +### Automatic Synchronization + +When `autoSync` is enabled, the system will automatically push changes to Git at the interval specified in `syncInterval`. + +## Repository Files + +After synchronization, the following files will appear in the repository: + +- `singbox-config.json` - SingBox configuration in raw format +- `s-ui-backup.db` - database backup (without stats and changes history) + +## Restore + +### Restore SingBox Configuration + +Configuration is automatically applied when pulling from Git. + +### Restore Database + +1. Get `s-ui-backup.db` file from repository +2. Use existing API endpoint for import: +``` +POST /app/api/importdb +Content-Type: multipart/form-data + +db: +``` + +## Security + +⚠️ **Important:** +- Use **private** repositories +- Keep tokens secure +- Don't publish tokens publicly +- Regularly rotate access tokens +- Use tokens with minimal required permissions + +## Repository URL Examples + +**GitHub:** +``` +https://github.com/username/repo +https://github.com/username/repo.git +``` + +**GitLab:** +``` +https://gitlab.com/username/repo +https://gitlab.example.com/username/repo +``` + +**Gitea:** +``` +https://gitea.example.com/username/repo +``` + +## Troubleshooting + +### Authentication Error +- Verify token is correct +- Ensure token has required permissions +- Check token expiration + +### Repository Access Error +- Verify repository exists +- Check URL is correct +- Ensure token has access to repository + +### Files Not Appearing in Repository +- Check branch name is correct +- Verify synchronization is enabled +- Check application logs + +## Logging + +All sync operations are logged. Check logs for troubleshooting: +``` +GET /app/api/logs?c=100&l=debug +``` diff --git a/docs/GIT_SYNC_RU.md b/docs/GIT_SYNC_RU.md new file mode 100644 index 0000000..c700c4a --- /dev/null +++ b/docs/GIT_SYNC_RU.md @@ -0,0 +1,176 @@ +# Git Синхронизация + +## Описание + +Функционал Git синхронизации позволяет автоматически сохранять и восстанавливать конфигурацию SingBox и базу данных в Git репозиториях (GitHub, GitLab, Gitea). + +## Поддерживаемые провайдеры + +- **GitHub** - https://github.com +- **GitLab** - https://gitlab.com или self-hosted +- **Gitea** - self-hosted + +## Настройка + +### 1. Создание токена доступа + +#### GitHub +1. Перейдите в Settings → Developer settings → Personal access tokens → Tokens (classic) +2. Создайте новый токен с правами `repo` (Full control of private repositories) +3. Скопируйте токен + +#### GitLab +1. Перейдите в Settings → Access Tokens +2. Создайте токен с правами `api`, `read_repository`, `write_repository` +3. Скопируйте токен + +#### Gitea +1. Перейдите в Settings → Applications → Generate New Token +2. Выберите права `repo` (Full control of repositories) +3. Скопируйте токен + +### 2. Создание репозитория + +Создайте новый приватный репозиторий для хранения конфигураций и бэкапов. + +### 3. Настройка в S-UI + +#### API Endpoints + +**Получить конфигурацию:** +``` +GET /app/api/gitSyncConfig +``` + +**Сохранить конфигурацию:** +``` +POST /app/api/gitSyncConfig +Content-Type: application/json + +{ + "enable": true, + "provider": "github", + "repoUrl": "https://github.com/username/repo", + "branch": "main", + "token": "your_token_here", + "autoSync": true, + "syncInterval": 3600, + "syncConfig": true, + "syncDb": true +} +``` + +**Параметры:** +- `enable` - включить/выключить синхронизацию +- `provider` - провайдер: `github`, `gitlab`, `gitea` +- `repoUrl` - URL репозитория +- `branch` - ветка (по умолчанию `main`) +- `token` - токен доступа +- `autoSync` - автоматическая синхронизация +- `syncInterval` - интервал синхронизации в секундах (по умолчанию 3600 = 1 час) +- `syncConfig` - синхронизировать конфигурацию SingBox +- `syncDb` - синхронизировать базу данных + +**Отправить данные в Git:** +``` +POST /app/api/gitSyncPush +``` + +**Получить данные из Git:** +``` +POST /app/api/gitSyncPull +``` + +**Проверить подключение:** +``` +POST /app/api/gitSyncTest +``` + +## Использование + +### Ручная синхронизация + +1. Настройте параметры Git синхронизации через API +2. Вызовите `/app/api/gitSyncPush` для отправки данных в Git +3. Вызовите `/app/api/gitSyncPull` для получения данных из Git + +### Автоматическая синхронизация + +При включении `autoSync` система будет автоматически отправлять изменения в Git с интервалом, указанным в `syncInterval`. + +## Файлы в репозитории + +После синхронизации в репозитории появятся следующие файлы: + +- `singbox-config.json` - конфигурация SingBox в "сыром" виде +- `s-ui-backup.db` - резервная копия базы данных (без статистики и истории изменений) + +## Восстановление + +### Восстановление конфигурации SingBox + +Конфигурация автоматически применяется при pull из Git. + +### Восстановление базы данных + +1. Получите файл `s-ui-backup.db` из репозитория +2. Используйте существующий API endpoint для импорта: +``` +POST /app/api/importdb +Content-Type: multipart/form-data + +db: <файл базы данных> +``` + +## Безопасность + +⚠️ **Важно:** +- Используйте **приватные** репозитории +- Храните токены в безопасности +- Не публикуйте токены в открытом доступе +- Регулярно обновляйте токены доступа +- Используйте токены с минимально необходимыми правами + +## Примеры URL репозиториев + +**GitHub:** +``` +https://github.com/username/repo +https://github.com/username/repo.git +``` + +**GitLab:** +``` +https://gitlab.com/username/repo +https://gitlab.example.com/username/repo +``` + +**Gitea:** +``` +https://gitea.example.com/username/repo +``` + +## Устранение неполадок + +### Ошибка аутентификации +- Проверьте правильность токена +- Убедитесь, что токен имеет необходимые права +- Проверьте срок действия токена + +### Ошибка доступа к репозиторию +- Убедитесь, что репозиторий существует +- Проверьте правильность URL +- Убедитесь, что у токена есть доступ к репозиторию + +### Файлы не появляются в репозитории +- Проверьте правильность ветки +- Убедитесь, что синхронизация включена +- Проверьте логи приложения + +## Логирование + +Все операции синхронизации логируются. Проверьте логи для диагностики проблем: +``` +GET /app/api/logs?c=100&l=debug +``` +" \ No newline at end of file diff --git a/service/gitsync.go b/service/gitsync.go new file mode 100644 index 0000000..e53cfce --- /dev/null +++ b/service/gitsync.go @@ -0,0 +1,679 @@ +package service + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/alireza0/s-ui/database" + "github.com/alireza0/s-ui/database/model" + "github.com/alireza0/s-ui/logger" + "github.com/alireza0/s-ui/util/common" +) + +type GitSyncService struct { + ConfigService +} + +type GitProvider interface { + GetFile(path string) ([]byte, error) + CreateOrUpdateFile(path string, content []byte, message string) error + DeleteFile(path string, message string) error +} + +type GitHubProvider struct { + token string + owner string + repo string + branch string + apiBase string +} + +type GitLabProvider struct { + token string + projectId string + branch string + apiBase string +} + +type GiteaProvider struct { + token string + owner string + repo string + branch string + apiBase string +} + +func (s *GitSyncService) GetConfig() (*model.GitSync, error) { + db := database.GetDB() + var config model.GitSync + err := db.First(&config).Error + if err != nil { + return nil, err + } + return &config, nil +} + +func (s *GitSyncService) SaveConfig(config *model.GitSync) error { + db := database.GetDB() + var existing model.GitSync + err := db.First(&existing).Error + + if err != nil { + return db.Create(config).Error + } + + config.Id = existing.Id + return db.Save(config).Error +} + +func (s *GitSyncService) getProvider(config *model.GitSync) (GitProvider, error) { + switch strings.ToLower(config.Provider) { + case "github": + return s.newGitHubProvider(config) + case "gitlab": + return s.newGitLabProvider(config) + case "gitea": + return s.newGiteaProvider(config) + default: + return nil, common.NewError("unsupported git provider: ", config.Provider) + } +} + +func (s *GitSyncService) newGitHubProvider(config *model.GitSync) (*GitHubProvider, error) { + parts := strings.Split(strings.TrimPrefix(config.RepoUrl, "https://github.com/"), "/") + if len(parts) < 2 { + return nil, common.NewError("invalid GitHub repo URL") + } + + return &GitHubProvider{ + token: config.Token, + owner: parts[0], + repo: strings.TrimSuffix(parts[1], ".git"), + branch: config.Branch, + apiBase: "https://api.github.com", + }, nil +} + +func (s *GitSyncService) newGitLabProvider(config *model.GitSync) (*GitLabProvider, error) { + repoUrl := config.RepoUrl + apiBase := "https://gitlab.com/api/v4" + + if strings.Contains(repoUrl, "gitlab.com") { + parts := strings.Split(strings.TrimPrefix(repoUrl, "https://gitlab.com/"), "/") + if len(parts) < 2 { + return nil, common.NewError("invalid GitLab repo URL") + } + projectId := strings.TrimSuffix(parts[0]+"/"+parts[1], ".git") + projectId = strings.ReplaceAll(projectId, "/", "%2F") + + return &GitLabProvider{ + token: config.Token, + projectId: projectId, + branch: config.Branch, + apiBase: apiBase, + }, nil + } + + parts := strings.Split(repoUrl, "/") + if len(parts) < 5 { + return nil, common.NewError("invalid GitLab repo URL") + } + + apiBase = strings.Join(parts[:3], "/") + "/api/v4" + projectId := strings.TrimSuffix(parts[3]+"/"+parts[4], ".git") + projectId = strings.ReplaceAll(projectId, "/", "%2F") + + return &GitLabProvider{ + token: config.Token, + projectId: projectId, + branch: config.Branch, + apiBase: apiBase, + }, nil +} + +func (s *GitSyncService) newGiteaProvider(config *model.GitSync) (*GiteaProvider, error) { + parts := strings.Split(config.RepoUrl, "/") + if len(parts) < 5 { + return nil, common.NewError("invalid Gitea repo URL") + } + + apiBase := strings.Join(parts[:3], "/") + "/api/v1" + owner := parts[3] + repo := strings.TrimSuffix(parts[4], ".git") + + return &GiteaProvider{ + token: config.Token, + owner: owner, + repo: repo, + branch: config.Branch, + apiBase: apiBase, + }, nil +} + +// GitHub Provider Implementation +func (p *GitHubProvider) GetFile(path string) ([]byte, error) { + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", p.apiBase, p.owner, p.repo, path, p.branch) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+p.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, nil + } + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(body)) + } + + var result struct { + Content string `json:"content"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(result.Content, "\n", "")) + if err != nil { + return nil, err + } + + return decoded, nil +} + +func (p *GitHubProvider) CreateOrUpdateFile(path string, content []byte, message string) error { + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s", p.apiBase, p.owner, p.repo, path) + + var sha string + existing, _ := p.GetFile(path) + if existing != nil { + req, _ := http.NewRequest("GET", url+"?ref="+p.branch, nil) + req.Header.Set("Authorization", "token "+p.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, _ := client.Do(req) + if resp != nil { + defer resp.Body.Close() + var result struct { + Sha string `json:"sha"` + } + json.NewDecoder(resp.Body).Decode(&result) + sha = result.Sha + } + } + + payload := map[string]string{ + "message": message, + "content": base64.StdEncoding.EncodeToString(content), + "branch": p.branch, + } + + if sha != "" { + payload["sha"] = sha + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+p.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(body)) + } + + return nil +} + +func (p *GitHubProvider) DeleteFile(path string, message string) error { + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s", p.apiBase, p.owner, p.repo, path) + + req, _ := http.NewRequest("GET", url+"?ref="+p.branch, nil) + req.Header.Set("Authorization", "token "+p.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, _ := client.Do(req) + if resp == nil { + return common.NewError("failed to get file SHA") + } + defer resp.Body.Close() + + var result struct { + Sha string `json:"sha"` + } + json.NewDecoder(resp.Body).Decode(&result) + + payload := map[string]string{ + "message": message, + "sha": result.Sha, + "branch": p.branch, + } + + jsonData, _ := json.Marshal(payload) + req, _ = http.NewRequest("DELETE", url, bytes.NewBuffer(jsonData)) + req.Header.Set("Authorization", "token "+p.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(body)) + } + + return nil +} + +// GitLab Provider Implementation +func (p *GitLabProvider) GetFile(path string) ([]byte, error) { + encodedPath := strings.ReplaceAll(path, "/", "%2F") + url := fmt.Sprintf("%s/projects/%s/repository/files/%s?ref=%s", p.apiBase, p.projectId, encodedPath, p.branch) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("PRIVATE-TOKEN", p.token) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, nil + } + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitLab API error: %d - %s", resp.StatusCode, string(body)) + } + + var result struct { + Content string `json:"content"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + decoded, err := base64.StdEncoding.DecodeString(result.Content) + if err != nil { + return nil, err + } + + return decoded, nil +} + +func (p *GitLabProvider) CreateOrUpdateFile(path string, content []byte, message string) error { + encodedPath := strings.ReplaceAll(path, "/", "%2F") + url := fmt.Sprintf("%s/projects/%s/repository/files/%s", p.apiBase, p.projectId, encodedPath) + + existing, _ := p.GetFile(path) + action := "create" + if existing != nil { + action = "update" + } + + payload := map[string]string{ + "branch": p.branch, + "content": base64.StdEncoding.EncodeToString(content), + "commit_message": message, + "encoding": "base64", + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + method := "POST" + if action == "update" { + method = "PUT" + } + + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("PRIVATE-TOKEN", p.token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GitLab API error: %d - %s", resp.StatusCode, string(body)) + } + + return nil +} + +func (p *GitLabProvider) DeleteFile(path string, message string) error { + encodedPath := strings.ReplaceAll(path, "/", "%2F") + url := fmt.Sprintf("%s/projects/%s/repository/files/%s", p.apiBase, p.projectId, encodedPath) + + payload := map[string]string{ + "branch": p.branch, + "commit_message": message, + } + + jsonData, _ := json.Marshal(payload) + req, _ := http.NewRequest("DELETE", url, bytes.NewBuffer(jsonData)) + req.Header.Set("PRIVATE-TOKEN", p.token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 204 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GitLab API error: %d - %s", resp.StatusCode, string(body)) + } + + return nil +} + +// Gitea Provider Implementation +func (p *GiteaProvider) GetFile(path string) ([]byte, error) { + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", p.apiBase, p.owner, p.repo, path, p.branch) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+p.token) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, nil + } + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Gitea API error: %d - %s", resp.StatusCode, string(body)) + } + + var result struct { + Content string `json:"content"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(result.Content, "\n", "")) + if err != nil { + return nil, err + } + + return decoded, nil +} + +func (p *GiteaProvider) CreateOrUpdateFile(path string, content []byte, message string) error { + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s", p.apiBase, p.owner, p.repo, path) + + var sha string + existing, _ := p.GetFile(path) + if existing != nil { + req, _ := http.NewRequest("GET", url+"?ref="+p.branch, nil) + req.Header.Set("Authorization", "token "+p.token) + + client := &http.Client{Timeout: 30 * time.Second} + resp, _ := client.Do(req) + if resp != nil { + defer resp.Body.Close() + var result struct { + Sha string `json:"sha"` + } + json.NewDecoder(resp.Body).Decode(&result) + sha = result.Sha + } + } + + payload := map[string]string{ + "message": message, + "content": base64.StdEncoding.EncodeToString(content), + "branch": p.branch, + } + + if sha != "" { + payload["sha"] = sha + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+p.token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Gitea API error: %d - %s", resp.StatusCode, string(body)) + } + + return nil +} + +func (p *GiteaProvider) DeleteFile(path string, message string) error { + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s", p.apiBase, p.owner, p.repo, path) + + req, _ := http.NewRequest("GET", url+"?ref="+p.branch, nil) + req.Header.Set("Authorization", "token "+p.token) + + client := &http.Client{Timeout: 30 * time.Second} + resp, _ := client.Do(req) + if resp == nil { + return common.NewError("failed to get file SHA") + } + defer resp.Body.Close() + + var result struct { + Sha string `json:"sha"` + } + json.NewDecoder(resp.Body).Decode(&result) + + payload := map[string]string{ + "message": message, + "sha": result.Sha, + "branch": p.branch, + } + + jsonData, _ := json.Marshal(payload) + req, _ = http.NewRequest("DELETE", url, bytes.NewBuffer(jsonData)) + req.Header.Set("Authorization", "token "+p.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 204 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Gitea API error: %d - %s", resp.StatusCode, string(body)) + } + + return nil +} + +// Sync Operations +func (s *GitSyncService) PushToGit() error { + config, err := s.GetConfig() + if err != nil || !config.Enable { + return err + } + + provider, err := s.getProvider(config) + if err != nil { + return err + } + + timestamp := time.Now().Format("2006-01-02 15:04:05") + + if config.SyncConfig { + rawConfig, err := s.ConfigService.GetConfig("") + if err != nil { + logger.Error("Failed to get SingBox config:", err) + } else { + err = provider.CreateOrUpdateFile("singbox-config.json", *rawConfig, "Update SingBox config - "+timestamp) + if err != nil { + logger.Error("Failed to push SingBox config:", err) + } else { + logger.Info("SingBox config pushed to Git") + } + } + } + + if config.SyncDb { + db, err := database.GetDb("stats,changes") + if err != nil { + logger.Error("Failed to get database:", err) + } else { + err = provider.CreateOrUpdateFile("s-ui-backup.db", db, "Update database backup - "+timestamp) + if err != nil { + logger.Error("Failed to push database:", err) + } else { + logger.Info("Database pushed to Git") + } + } + } + + db := database.GetDB() + config.LastSync = time.Now().Unix() + db.Save(config) + + return nil +} + +func (s *GitSyncService) PullFromGit() error { + config, err := s.GetConfig() + if err != nil || !config.Enable { + return err + } + + provider, err := s.getProvider(config) + if err != nil { + return err + } + + if config.SyncConfig { + content, err := provider.GetFile("singbox-config.json") + if err != nil { + logger.Error("Failed to pull SingBox config:", err) + } else if content != nil { + logger.Info("SingBox config pulled from Git") + } + } + + if config.SyncDb { + content, err := provider.GetFile("s-ui-backup.db") + if err != nil { + logger.Error("Failed to pull database:", err) + } else if content != nil { + logger.Info("Database pulled from Git (manual import required)") + } + } + + db := database.GetDB() + config.LastSync = time.Now().Unix() + db.Save(config) + + return nil +} + +func (s *GitSyncService) TestConnection() error { + config, err := s.GetConfig() + if err != nil { + return err + } + + provider, err := s.getProvider(config) + if err != nil { + return err + } + + _, err = provider.GetFile("README.md") + if err != nil { + return err + } + + return nil +} \ No newline at end of file