mirror of
https://github.com/alireza0/s-ui.git
synced 2026-08-27 04:17:00 +00:00
add git 1 eter
This commit is contained in:
parent
050bf6edf5
commit
0f44c16f43
11 changed files with 1422 additions and 7 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
40
cronjob/gitSyncJob.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
15
database/model/gitsync.go
Normal 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"`
|
||||
}
|
||||
269
docs/GIT_SYNC_API_EXAMPLES.md
Normal file
269
docs/GIT_SYNC_API_EXAMPLES.md
Normal 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
175
docs/GIT_SYNC_EN.md
Normal 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
176
docs/GIT_SYNC_RU.md
Normal 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
679
service/gitsync.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue