add git 1 eter

This commit is contained in:
Pushkinmazila2 2026-04-20 23:11:10 +03:00
parent 050bf6edf5
commit 0f44c16f43
11 changed files with 1422 additions and 7 deletions

View file

@ -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))
}

View file

@ -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)
}

View file

@ -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

40
cronjob/gitSyncJob.go Normal file
View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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

15
database/model/gitsync.go Normal file
View file

@ -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"`
}

View file

@ -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

175
docs/GIT_SYNC_EN.md Normal file
View file

@ -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: <database file>
```
## 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
```

176
docs/GIT_SYNC_RU.md Normal file
View file

@ -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
```
"

679
service/gitsync.go Normal file
View file

@ -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
}