mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 17:00:18 +00:00
Compare commits
52
Commits
1.0.3-fix1
...
1.0.4-fix2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2dae1ec7b | ||
|
|
59d923e168 | ||
|
|
51844c27f1 | ||
|
|
53171affca | ||
|
|
d31d12e59f | ||
|
|
3715f2b3f9 | ||
|
|
cc3d54bff4 | ||
|
|
ef376a88b8 | ||
|
|
e639238f0e | ||
|
|
cf8241a77a | ||
|
|
eb87a4fc33 | ||
|
|
0727fb4343 | ||
|
|
d55d759fec | ||
|
|
aad279ce55 | ||
|
|
e9b1a6b2cd | ||
|
|
bd5a6934e1 | ||
|
|
2f04008453 | ||
|
|
fbaa410fd4 | ||
|
|
1d4f6f2814 | ||
|
|
ad2f5160fb | ||
|
|
d284405310 | ||
|
|
ff172ccbf4 | ||
|
|
14635948b6 | ||
|
|
012fd1dcc2 | ||
|
|
94fa5862b1 | ||
|
|
e7ec6c71f7 | ||
|
|
59b187b2a6 | ||
|
|
5e7cb0f9f1 | ||
|
|
dfe51b0dc5 | ||
|
|
7a4dd4bf66 | ||
|
|
0b34beea59 | ||
|
|
90ee9bc731 | ||
|
|
046f38e7c4 | ||
|
|
eb733cea45 | ||
|
|
eb68f0c629 | ||
|
|
1cbcac4a00 | ||
|
|
2d6d58edab | ||
|
|
c01fadbb48 | ||
|
|
41ef2e239d | ||
|
|
7176180343 | ||
|
|
0b9a01c8ac | ||
|
|
50ef62a64a | ||
|
|
384fa4bbe1 | ||
|
|
10f84a8052 | ||
|
|
53bd83af8b | ||
|
|
53577e21e7 | ||
|
|
46c0cb9bfb | ||
|
|
ac8ee8f9fa | ||
|
|
787e8bdd80 | ||
|
|
83a39e3143 | ||
|
|
5e2ed79929 | ||
|
|
c8f0ba926d |
No files matched your search
@@ -0,0 +1,109 @@
|
||||
name: Auto Merge Dev to Main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize] # No longer need 'closed' trigger
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
# Job to enable Auto-Merge for the PR
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for open PRs from dev to main
|
||||
if: github.head_ref == 'dev' && github.base_ref == 'main'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Required for git history
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
- name: Get PR number
|
||||
id: get-pr
|
||||
run: |
|
||||
echo "pr_number=${{ github.event.number }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for conflicts
|
||||
id: check-conflicts
|
||||
run: |
|
||||
# Ensure local branches are up-to-date
|
||||
git fetch origin main
|
||||
git fetch origin dev
|
||||
|
||||
# Use merge-base to find the common ancestor and check for conflicts
|
||||
if git merge-tree $(git merge-base origin/main origin/dev) origin/main origin/dev | grep -q '<<<<<<<'; then
|
||||
echo "has_conflicts=true" >> $GITHUB_OUTPUT
|
||||
echo "❌ Merge conflicts detected"
|
||||
else
|
||||
echo "has_conflicts=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ No merge conflicts"
|
||||
fi
|
||||
|
||||
- name: Add conflict comment
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "❌ **Merge Conflict Detection**
|
||||
|
||||
This PR has merge conflicts and cannot be automatically merged. Please resolve conflicts manually and push to the dev branch.
|
||||
|
||||
Steps to resolve conflicts:
|
||||
1. \`git checkout dev\`
|
||||
2. \`git merge main\`
|
||||
3. Resolve conflicts
|
||||
4. \`git commit\`
|
||||
5. \`git push origin dev\`"
|
||||
exit 1 # Exit to prevent further steps
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'false'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Enable auto-merge using a merge commit
|
||||
gh pr merge ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--auto \
|
||||
--merge \
|
||||
--delete-branch=false
|
||||
|
||||
# Add success comment
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "✅ **Auto-merge Enabled**
|
||||
|
||||
This PR has no merge conflicts and auto-merge has been enabled. It will be automatically merged to the main branch once all checks pass.
|
||||
|
||||
Merge method: Merge commit
|
||||
Branch retention: dev branch will be preserved"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### 🤖 Auto-merge Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 📝 Processing PR #${{ steps.get-pr.outputs.pr_number }} (dev → main)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "${{ steps.check-conflicts.outputs.has_conflicts }}" == "true" ]; then
|
||||
echo "- ❌ Merge conflicts detected, manual resolution required" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- ✅ No merge conflicts, auto-merge enabled" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**PR Link**: https://github.com/${{ github.repository }}/pull/${{ steps.get-pr.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -7,6 +7,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
__debug*
|
||||
|
||||
# local development data
|
||||
komari.db
|
||||
|
||||
@@ -10,7 +10,14 @@ import (
|
||||
|
||||
func AdminAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// session-based authentication
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
@@ -119,3 +120,14 @@ func GetVersion(c *gin.Context) {
|
||||
"hash": utils.VersionHash,
|
||||
})
|
||||
}
|
||||
|
||||
func isApiKeyValid(apiKey string) bool {
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
|
||||
return false
|
||||
}
|
||||
return apiKey == "Bearer "+cfg.ApiKey
|
||||
}
|
||||
@@ -25,6 +25,13 @@ var (
|
||||
|
||||
func PrivateSiteMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// 如果是公开的路径,直接放行
|
||||
for _, path := range publicPaths {
|
||||
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
|
||||
|
||||
@@ -16,6 +16,14 @@ import (
|
||||
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
|
||||
func TokenAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
var token string
|
||||
|
||||
// Step 1: Check query parameter for token
|
||||
|
||||
@@ -68,6 +68,7 @@ func RemoveClient(c *gin.Context) {
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
ws.DeleteConnectedClients(uuid)
|
||||
ws.DeleteLatestReport(uuid)
|
||||
}
|
||||
|
||||
|
||||
+93
-14
@@ -14,18 +14,92 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
)
|
||||
|
||||
// copyDatabaseToTemp 安全地复制数据库文件到临时目录
|
||||
// 对于SQLite数据库,会使用BACKUP命令确保数据一致性
|
||||
func copyDatabaseToTemp(dbFilePath, tempDir string) (string, error) {
|
||||
if dbFilePath == "" {
|
||||
return "", fmt.Errorf("database file path is empty")
|
||||
}
|
||||
|
||||
// 检查数据库文件是否存在
|
||||
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("database file does not exist: %s", dbFilePath)
|
||||
}
|
||||
|
||||
dbFileName := filepath.Base(dbFilePath)
|
||||
tempDbPath := filepath.Join(tempDir, dbFileName)
|
||||
|
||||
// 如果是SQLite数据库,使用SQLite的BACKUP命令来确保一致性
|
||||
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
// 获取底层的SQLite连接
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get underlying database connection: %v", err)
|
||||
}
|
||||
|
||||
// 使用SQLite的BACKUP API来创建一致的备份
|
||||
backupSQL := fmt.Sprintf("BACKUP DATABASE main TO '%s'", tempDbPath)
|
||||
_, err = sqlDB.Exec(backupSQL)
|
||||
if err != nil {
|
||||
// 如果BACKUP命令失败,回退到文件复制方法
|
||||
log.Printf("SQLite BACKUP command failed, falling back to file copy: %v", err)
|
||||
return copyFileToTemp(dbFilePath, tempDbPath)
|
||||
}
|
||||
|
||||
log.Printf("Database backed up using SQLite BACKUP to: %s", tempDbPath)
|
||||
return tempDbPath, nil
|
||||
} else {
|
||||
// 对于非SQLite数据库,直接复制文件
|
||||
return copyFileToTemp(dbFilePath, tempDbPath)
|
||||
}
|
||||
}
|
||||
|
||||
// copyFileToTemp 简单的文件复制到临时目录
|
||||
func copyFileToTemp(srcPath, destPath string) (string, error) {
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open source file: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create destination file: %v", err)
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
_, err = io.Copy(dest, src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to copy file: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Database file copied to: %s", destPath)
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
|
||||
func DownloadBackup(c *gin.Context) {
|
||||
backupFileName := fmt.Sprintf("backup-%s.zip", time.Now().Format("20060102-150405"))
|
||||
backupFileName := fmt.Sprintf("backup-%d.zip", time.Now().UnixMicro())
|
||||
c.Writer.Header().Set("Content-Type", "application/zip")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
|
||||
|
||||
zipWriter := zip.NewWriter(c.Writer)
|
||||
defer zipWriter.Close()
|
||||
|
||||
err := filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
|
||||
// 创建临时目录用于存放数据库备份
|
||||
tempDir, err := os.MkdirTemp("", "komari-backup-*")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // 清理临时目录
|
||||
|
||||
err = filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,27 +170,32 @@ func DownloadBackup(c *gin.Context) {
|
||||
|
||||
// 如果数据库文件的绝对路径不是以 ./data 目录的绝对路径开头,则单独添加它
|
||||
if !strings.HasPrefix(dbAbsPath, dataAbsPath) {
|
||||
dbFileName := filepath.Base(dbFilePath)
|
||||
dbInfo, err := os.Stat(dbFilePath)
|
||||
// 使用安全的数据库复制方法
|
||||
tempDbPath, err := copyDatabaseToTemp(dbFilePath, tempDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
|
||||
return // 数据库不存在是正常情况,直接返回
|
||||
} else {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
|
||||
return
|
||||
}
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if !dbInfo.IsDir() {
|
||||
file, err := os.Open(dbFilePath)
|
||||
} else {
|
||||
// 将临时数据库文件添加到zip
|
||||
dbInfo, err := os.Stat(tempDbPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating temp database file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(tempDbPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening temp database file: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: dbFileName,
|
||||
Name: filepath.Base(dbFilePath), // 使用原始数据库文件名
|
||||
Method: zip.Deflate,
|
||||
Modified: dbInfo.ModTime(),
|
||||
})
|
||||
@@ -136,7 +215,7 @@ func DownloadBackup(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 添加备份标记文件
|
||||
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format("2006-01-02 15:04:05")
|
||||
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format(time.RFC3339)
|
||||
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: "komari-backup-markup",
|
||||
Method: zip.Deflate,
|
||||
|
||||
+28
-11
@@ -2,11 +2,13 @@ package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
@@ -21,16 +23,25 @@ func Exec(c *gin.Context) {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
}
|
||||
var onlineClients []string
|
||||
var offlineClients []string
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
for uuid := range ws.GetConnectedClients() {
|
||||
if contain(req.Clients, uuid) {
|
||||
// for uuid := range ws.GetConnectedClients() {
|
||||
// if contain(req.Clients, uuid) {
|
||||
// onlineClients = append(onlineClients, uuid)
|
||||
// }
|
||||
// // else {
|
||||
// // api.RespondError(c, 400, "Client not connected: "+uuid)
|
||||
// // return
|
||||
// // }
|
||||
// }
|
||||
for _, uuid := range req.Clients {
|
||||
if client := ws.GetConnectedClients()[uuid]; client != nil {
|
||||
onlineClients = append(onlineClients, uuid)
|
||||
} else {
|
||||
api.RespondError(c, 400, "Client not connected: "+uuid)
|
||||
return
|
||||
offlineClients = append(offlineClients, uuid)
|
||||
}
|
||||
}
|
||||
if len(onlineClients) == 0 {
|
||||
@@ -38,7 +49,7 @@ func Exec(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
taskId := utils.GenerateRandomString(16)
|
||||
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
|
||||
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
|
||||
api.RespondError(c, 500, "Failed to create task: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -67,12 +78,18 @@ func Exec(c *gin.Context) {
|
||||
"task_id": taskId,
|
||||
"clients": onlineClients,
|
||||
})
|
||||
}
|
||||
func contain(clients []string, uuid string) bool {
|
||||
for _, client := range clients {
|
||||
if client == uuid {
|
||||
return true
|
||||
if len(offlineClients) > 0 {
|
||||
for _, uuid := range offlineClients {
|
||||
tasks.SaveTaskResult(taskId, uuid, "Client offline!", -1, models.FromTime(time.Now()))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// func contain(clients []string, uuid string) bool {
|
||||
// for _, client := range clients {
|
||||
// if client == uuid {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
@@ -3,9 +3,11 @@ package admin
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -281,3 +283,307 @@ func isValidThemeShort(short string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// downloadThemeFromURL 从URL下载主题文件
|
||||
func downloadThemeFromURL(url string) ([]byte, error) {
|
||||
// 发送HTTP GET请求
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载主题文件失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("下载主题文件失败,HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取主题文件内容失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("下载的主题文件为空")
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// getGitHubReleaseDownloadURL 从GitHub API获取最新release的下载链接
|
||||
// 该函数通过GitHub API获取指定仓库最新release的资源下载链接
|
||||
// 参考API: https://api.github.com/repos/{owner}/{repo}/releases/latest
|
||||
// 参数:
|
||||
// - owner: GitHub仓库所有者
|
||||
// - repo: GitHub仓库名称
|
||||
//
|
||||
// 返回:
|
||||
// - 最新release的第一个资源的下载链接
|
||||
// - 错误信息(如果有)
|
||||
func getGitHubReleaseDownloadURL(owner, repo string) (string, error) {
|
||||
if owner == "" || repo == "" {
|
||||
return "", errors.New("GitHub仓库所有者和仓库名称不能为空")
|
||||
}
|
||||
|
||||
// 构建GitHub API URL
|
||||
// 使用GitHub API获取最新release信息
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||
|
||||
// 发送HTTP GET请求
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取GitHub release信息失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("获取GitHub release信息失败,HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析JSON响应
|
||||
// GitHub API返回的JSON包含assets数组,每个asset包含browser_download_url字段
|
||||
var releaseInfo struct {
|
||||
Assets []struct {
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releaseInfo); err != nil {
|
||||
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查是否有可下载的资源
|
||||
if len(releaseInfo.Assets) == 0 {
|
||||
return "", errors.New("GitHub release中没有可下载的资源")
|
||||
}
|
||||
|
||||
// 返回第一个资源的下载链接
|
||||
// 相当于shell命令: curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url"
|
||||
return releaseInfo.Assets[0].BrowserDownloadURL, nil
|
||||
}
|
||||
|
||||
// isGitHubRepoURL 检查URL是否是GitHub仓库地址
|
||||
// 支持的格式:
|
||||
// - https://github.com/owner/repo
|
||||
// - https://github.com/owner/repo.git
|
||||
// - https://www.github.com/owner/repo
|
||||
// - http://github.com/owner/repo
|
||||
// 返回:
|
||||
// - 是否是GitHub仓库URL
|
||||
// - 仓库所有者
|
||||
// - 仓库名称
|
||||
func isGitHubRepoURL(urlStr string) (bool, string, string) {
|
||||
if urlStr == "" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 检查URL是否包含github.com
|
||||
if !strings.Contains(strings.ToLower(urlStr), "github.com") {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 解析URL
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 检查主机名是否是github.com或www.github.com
|
||||
hostname := strings.ToLower(parsedURL.Host)
|
||||
if hostname != "github.com" && hostname != "www.github.com" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 解析路径部分,提取owner和repo
|
||||
// 路径格式应该是 /owner/repo 或 /owner/repo.git
|
||||
path := strings.TrimPrefix(parsedURL.Path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) < 2 {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
owner := parts[0]
|
||||
repo := parts[1]
|
||||
|
||||
// 如果repo以.git结尾,去掉这个后缀
|
||||
repo = strings.TrimSuffix(repo, ".git")
|
||||
|
||||
return true, owner, repo
|
||||
}
|
||||
|
||||
// UpdateTheme 更新主题
|
||||
// 支持四种更新方式:
|
||||
// 1. 使用主题原有URL下载更新
|
||||
// 2. 提供新的直接下载URL进行更新
|
||||
// 3. 提供GitHub仓库信息,从最新release下载更新
|
||||
// 4. 如果主题URL是GitHub仓库地址,自动获取最新release
|
||||
func UpdateTheme(c *gin.Context) {
|
||||
var req struct {
|
||||
Short string `json:"short" binding:"required"` // 主题短名称
|
||||
URL string `json:"url"` // 新的URL地址(可选)
|
||||
GitOwner string `json:"git_owner"` // GitHub仓库所有者(可选)
|
||||
GitRepo string `json:"git_repo"` // GitHub仓库名称(可选)
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查主题是否存在
|
||||
themeDir := filepath.Join("./data/theme", req.Short)
|
||||
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
|
||||
|
||||
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 加载现有主题配置
|
||||
themeInfo, err := loadThemeConfig(themeConfigPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 方式1和方式4: 尝试从原始URL下载主题
|
||||
// 如果原始URL是GitHub仓库地址,则自动获取最新release
|
||||
var themeData []byte
|
||||
// 不保存下载链接,更新后由主题覆盖
|
||||
//var downloadURL string
|
||||
// var err2 error
|
||||
|
||||
if themeInfo.URL != "" {
|
||||
// 检查原始URL是否是GitHub仓库地址
|
||||
// 例如: https://github.com/owner/repo
|
||||
isGitHub, owner, repo := isGitHubRepoURL(themeInfo.URL)
|
||||
if isGitHub {
|
||||
// 方式4: 如果原始URL是GitHub仓库地址,自动获取最新release
|
||||
// 这是本次需求的核心功能:当主题文件中现有的url地址如果是github仓库的路径,则直接引用该url地址去下载最新的release
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err == nil {
|
||||
// 使用获取到的GitHub release下载链接下载主题
|
||||
themeData, _ = downloadThemeFromURL(gitHubURL)
|
||||
//if err2 == nil {
|
||||
// 注意:这里我们保存的是release的下载链接,而不是GitHub仓库地址
|
||||
// 这样做是为了在下载成功后,将这个具体的release下载链接保存到主题配置中
|
||||
// 但在下次更新时,我们仍然会检测到这是一个GitHub仓库,并获取最新的release
|
||||
// downloadURL = gitHubURL
|
||||
//}
|
||||
}
|
||||
} else {
|
||||
// 原始URL不是GitHub仓库地址,直接尝试下载(方式1)
|
||||
themeData, _ = downloadThemeFromURL(themeInfo.URL)
|
||||
//if err2 == nil {
|
||||
// downloadURL = themeInfo.URL
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果原始URL下载失败,尝试其他方式下载
|
||||
if themeData == nil || len(themeData) == 0 {
|
||||
// 方式3: 如果提供了GitHub仓库信息,尝试从GitHub最新release下载
|
||||
// 这种方式允许用户只需提供owner和repo信息,系统会自动获取最新release的下载链接
|
||||
if req.GitOwner != "" && req.GitRepo != "" {
|
||||
// 从GitHub API获取下载链接
|
||||
// 相当于: DOWNLOAD_URL=$(curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url")
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(req.GitOwner, req.GitRepo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 使用获取到的链接下载主题
|
||||
themeData, err = downloadThemeFromURL(gitHubURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 保存下载链接,稍后更新到主题配置中
|
||||
// downloadURL = gitHubURL
|
||||
} else if req.URL != "" {
|
||||
// 方式2: 如果提供了新URL,尝试从新URL下载
|
||||
// 检查新URL是否是GitHub仓库地址
|
||||
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
|
||||
if isGitHub {
|
||||
// 如果新URL是GitHub仓库地址,获取最新release
|
||||
// 这里也应用了自动检测GitHub仓库并下载最新release的功能
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 使用获取到的链接下载主题
|
||||
themeData, err = downloadThemeFromURL(gitHubURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 保存GitHub仓库URL,而不是release下载链接,以便将来可以获取最新版本
|
||||
// 这是一个重要的设计决策:我们保存的是GitHub仓库URL,而不是具体的release下载链接
|
||||
// 这样在下次更新时,系统会再次检测到这是GitHub仓库,并自动获取最新的release
|
||||
// downloadURL = req.URL
|
||||
} else {
|
||||
// 新URL不是GitHub仓库地址,直接尝试下载
|
||||
themeData, err = downloadThemeFromURL(req.URL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// downloadURL = req.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有成功下载主题数据
|
||||
if themeData == nil || len(themeData) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
|
||||
return
|
||||
}
|
||||
|
||||
// 到这里,我们已经成功获取了主题数据,可能是通过以下四种方式之一:
|
||||
// 1. 原始URL直接下载
|
||||
// 2. 原始URL是GitHub仓库,自动获取最新release下载
|
||||
// 3. 用户提供的新URL下载
|
||||
// 4. 用户提供的GitHub仓库信息,获取最新release下载
|
||||
|
||||
// 临时文件名
|
||||
tempFile := filepath.Join(os.TempDir(), "downloaded_theme.zip")
|
||||
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tempFile)
|
||||
|
||||
// 解压ZIP文件并验证
|
||||
updatedThemeInfo, err := extractAndValidateTheme(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 如果下载URL与原始URL不同,更新主题配置中的URL
|
||||
// if downloadURL != themeInfo.URL {
|
||||
// updatedThemeInfo.URL = downloadURL
|
||||
|
||||
// // 更新主题配置文件
|
||||
// updatedConfigPath := filepath.Join("./data/theme", updatedThemeInfo.Short, "komari-theme.json")
|
||||
// updatedConfigData, err := json.MarshalIndent(updatedThemeInfo, "", " ")
|
||||
// if err != nil {
|
||||
// api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
// return
|
||||
// }
|
||||
|
||||
// if err := os.WriteFile(updatedConfigPath, updatedConfigData, 0644); err != nil {
|
||||
// api.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
func RegisterClient(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.AutoDiscoveryKey == "" ||
|
||||
len(cfg.AutoDiscoveryKey) < 12 ||
|
||||
"Bearer "+cfg.AutoDiscoveryKey != auth {
|
||||
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
name = utils.GenerateRandomString(8)
|
||||
}
|
||||
name = "Auto-" + name
|
||||
uuid, token, err := clients.CreateClientWithName(name)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to create client: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func WebSocketReport(c *gin.Context) {
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
return true // 被控
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
@@ -26,7 +27,7 @@ func TaskResult(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, models.FromTime(req.FinishedAt)); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func EstablishConnection(c *gin.Context) {
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // TODO: 检查源站
|
||||
return true // 被控
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
|
||||
+9
-7
@@ -48,13 +48,15 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
// 2FA
|
||||
user, _ := accounts.GetUserByUUID(uuid)
|
||||
if user.TwoFactor != "" && data.TwoFa == "" {
|
||||
RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
|
||||
return
|
||||
if user.TwoFactor != "" { // 开启了2FA
|
||||
if data.TwoFa == "" {
|
||||
RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Create session
|
||||
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
|
||||
|
||||
+3
-2
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ func OAuth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
authURL, state := oauth.CurrentProvider().GetAuthorizationURL()
|
||||
authURL, state := oauth.CurrentProvider().GetAuthorizationURL(utils.GetCallbackURL(c))
|
||||
|
||||
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
|
||||
|
||||
@@ -41,7 +42,7 @@ func OAuthCallback(c *gin.Context) {
|
||||
queries[key] = values[0]
|
||||
}
|
||||
}
|
||||
oidcUser, err := oauth.CurrentProvider().OnCallback(c.Request.Context(), state, queries)
|
||||
oidcUser, err := oauth.CurrentProvider().OnCallback(c.Request.Context(), state, queries, utils.GetCallbackURL(c))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
|
||||
return
|
||||
|
||||
+26
-15
@@ -1,6 +1,7 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -85,7 +86,7 @@ func GetPingRecords(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
if len(records) > 0 {
|
||||
if len(records) >= 0 {
|
||||
// 获取当前属于该 uuid 的 pingTasks
|
||||
pingTasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
@@ -101,24 +102,34 @@ func GetPingRecords(c *gin.Context) {
|
||||
tasksList := make([]gin.H, 0, len(pingTasks))
|
||||
for _, t := range pingTasks {
|
||||
// 只保留分配给该 uuid 的任务
|
||||
found := false
|
||||
for _, client := range t.Clients {
|
||||
if client == uuid {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
found := slices.Contains(t.Clients, uuid)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
// 只返回有记录的任务
|
||||
if _, ok := taskIdSet[t.Id]; ok {
|
||||
tasksList = append(tasksList, gin.H{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"interval": t.Interval,
|
||||
})
|
||||
|
||||
// 计算该任务的丢包率
|
||||
totalCount := 0
|
||||
lossCount := 0
|
||||
for _, r := range records {
|
||||
if r.TaskId == t.Id {
|
||||
totalCount++
|
||||
if r.Value < 0 {
|
||||
lossCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lossRate float64 = 0
|
||||
if totalCount > 0 {
|
||||
lossRate = float64(lossCount) / float64(totalCount) * 100
|
||||
}
|
||||
|
||||
tasksList = append(tasksList, gin.H{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"interval": t.Interval,
|
||||
"loss": lossRate,
|
||||
})
|
||||
}
|
||||
response.Tasks = tasksList
|
||||
}
|
||||
|
||||
+1
-3
@@ -30,9 +30,7 @@ func RequestTerminal(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
CheckOrigin: ws.CheckOrigin,
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -167,6 +167,7 @@ func RunServer() {
|
||||
r.GET("/api/records/load", record.GetRecordsByUUID)
|
||||
r.GET("/api/records/ping", record.GetPingRecords)
|
||||
// #region Agent
|
||||
r.POST("/api/clients/register", client.RegisterClient)
|
||||
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
|
||||
{
|
||||
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
|
||||
@@ -221,6 +222,7 @@ func RunServer() {
|
||||
themeGroup.GET("/list", admin.ListThemes)
|
||||
themeGroup.POST("/delete", admin.DeleteTheme)
|
||||
themeGroup.GET("/set", admin.SetTheme)
|
||||
themeGroup.POST("/update", admin.UpdateTheme)
|
||||
}
|
||||
// clients
|
||||
clientGroup := adminAuthrized.Group("/client")
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package models
|
||||
|
||||
type Config struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
|
||||
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
|
||||
|
||||
@@ -83,7 +83,7 @@ func DeleteTaskByTaskId(taskId string) error {
|
||||
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
|
||||
}
|
||||
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
|
||||
return dbcore.GetDBInstance().
|
||||
Model(&models.TaskResult{}).
|
||||
Where("task_id = ? AND client = ?", taskId, clientId).
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ log_step() {
|
||||
|
||||
# Global variables
|
||||
INSTALL_DIR="/opt/komari"
|
||||
DATA_DIR="/opt/komari/data"
|
||||
DATA_DIR="/opt/komari"
|
||||
SERVICE_NAME="komari"
|
||||
BINARY_PATH="$INSTALL_DIR/komari"
|
||||
DEFAULT_PORT="25774"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// https://github.com/labstack/echo/blob/98ca08e7dd64075b858e758d6693bf9799340756/context.go#L275-L294
|
||||
func GetScheme(c *gin.Context) string {
|
||||
// Can't use `r.Request.URL.Scheme`
|
||||
// See: https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0
|
||||
if c.Request.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if scheme := c.Request.Header.Get("X-Forwarded-Proto"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
if scheme := c.Request.Header.Get("X-Forwarded-Protocol"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
if ssl := c.Request.Header.Get("X-Forwarded-Ssl"); ssl == "on" {
|
||||
return "https"
|
||||
}
|
||||
if scheme := c.Request.Header.Get("X-Url-Scheme"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func GetCallbackURL(c *gin.Context) string {
|
||||
scheme := GetScheme(c)
|
||||
host := c.Request.Host
|
||||
return scheme + "://" + host + "/api/oauth_callback"
|
||||
}
|
||||
+5
-1
@@ -20,10 +20,14 @@ func Parse(v any) []Item {
|
||||
var items []Item
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
typ := field.Tag.Get("type")
|
||||
if typ != "option" {
|
||||
typ = field.Type.Name()
|
||||
}
|
||||
item := Item{
|
||||
Name: field.Tag.Get("json"),
|
||||
Required: field.Tag.Get("required") == "true",
|
||||
Type: field.Type.Name(),
|
||||
Type: typ,
|
||||
Options: field.Tag.Get("options"),
|
||||
Default: field.Tag.Get("default"),
|
||||
Help: field.Tag.Get("help"),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package messageSender
|
||||
|
||||
import (
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/bark"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/email"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
|
||||
)
|
||||
|
||||
func All() {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package bark
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type BarkSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
func (b *BarkSender) GetName() string {
|
||||
return "bark"
|
||||
}
|
||||
|
||||
func (b *BarkSender) GetConfiguration() factory.Configuration {
|
||||
return &b.Addition
|
||||
}
|
||||
|
||||
func (b *BarkSender) Init() error {
|
||||
// 验证 ServerURL 格式
|
||||
if b.Addition.ServerURL != "" {
|
||||
if _, err := url.Parse(b.Addition.ServerURL); err != nil {
|
||||
return fmt.Errorf("invalid server URL: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BarkSender) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BarkSender) SendTextMessage(message, title string) error {
|
||||
if b.Addition.DeviceKey == "" {
|
||||
return fmt.Errorf("device key is required")
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
return fmt.Errorf("message is empty")
|
||||
}
|
||||
|
||||
// 准备请求数据
|
||||
payload := map[string]interface{}{
|
||||
"body": message,
|
||||
"device_key": b.Addition.DeviceKey,
|
||||
}
|
||||
|
||||
// 如果有标题,添加标题
|
||||
if title != "" {
|
||||
payload["title"] = title
|
||||
}
|
||||
|
||||
// 添加可选参数
|
||||
if b.Addition.Icon != "" {
|
||||
payload["icon"] = b.Addition.Icon
|
||||
}
|
||||
|
||||
if b.Addition.Level != "" {
|
||||
payload["level"] = b.Addition.Level
|
||||
}
|
||||
|
||||
// 序列化 JSON
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %v", err)
|
||||
}
|
||||
|
||||
// 构建请求 URL
|
||||
serverURL := b.Addition.ServerURL
|
||||
if serverURL == "" {
|
||||
serverURL = "https://api.day.app"
|
||||
}
|
||||
|
||||
// 确保 URL 以正确的格式结尾
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
requestURL := fmt.Sprintf("%s/push", serverURL)
|
||||
|
||||
// 发送 HTTP POST 请求
|
||||
resp, err := http.Post(requestURL, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("bark API returned non-OK status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
// 如果解析失败,认为发送成功(某些 Bark 服务器可能返回纯文本)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查 Bark API 响应
|
||||
if result.Code != 200 {
|
||||
return fmt.Errorf("bark API error (code: %d): %s", result.Code, result.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &BarkSender{}
|
||||
})
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*BarkSender)(nil)
|
||||
@@ -0,0 +1,18 @@
|
||||
package bark
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
ServerURL string `json:"server_url" required:"true" default:"https://api.day.app" help:"Bark server URL, e.g., https://api.day.app or your self-hosted server address"`
|
||||
DeviceKey string `json:"device_key" required:"true" help:"Your Bark device key, which can be found in the Bark App"`
|
||||
Icon string `json:"icon" help:"Push notification icon, supports URL or system icon name"`
|
||||
Level string `json:"level" type:"option" default:"timeSensitive" options:"active,timeSensitive,passive,critical" help:"Push notification level: active, timeSensitive (default), passive, critical"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &BarkSender{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
URL string `json:"url" required:"true"`
|
||||
Method string `json:"method" default:"GET" type:"option" options:"POST,GET"`
|
||||
ContentType string `json:"content_type" default:"application/json"`
|
||||
Headers string `json:"headers" help:"HTTP headers in JSON format"`
|
||||
Body string `json:"body" default:"{\"message\":\"{{message}}\",\"title\":\"{{title}}\"}"` // 默认使用message和title字段
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &WebhookSender{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type WebhookSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
func (w *WebhookSender) GetName() string {
|
||||
return "webhook"
|
||||
}
|
||||
|
||||
func (w *WebhookSender) GetConfiguration() factory.Configuration {
|
||||
return &w.Addition
|
||||
}
|
||||
|
||||
func (w *WebhookSender) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookSender) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookSender) SendTextMessage(message, title string) error {
|
||||
if w.Addition.URL == "" {
|
||||
return fmt.Errorf("webhook URL is not configured")
|
||||
}
|
||||
|
||||
method := strings.ToUpper(w.Addition.Method)
|
||||
if method == "" {
|
||||
method = "GET" // 默认使用 GET
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second, // 固定30秒超时
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
switch method {
|
||||
case "POST":
|
||||
req, err = w.createPOSTRequest(message, title)
|
||||
case "GET":
|
||||
req, err = w.createGETRequest(message, title)
|
||||
default:
|
||||
return fmt.Errorf("unsupported HTTP method: %s", method)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
// 解析并设置自定义头部
|
||||
if w.Addition.Headers != "" {
|
||||
var headers map[string]string
|
||||
if err := json.Unmarshal([]byte(w.Addition.Headers), &headers); err == nil {
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置基本认证
|
||||
if w.Addition.Username != "" && w.Addition.Password != "" {
|
||||
req.SetBasicAuth(w.Addition.Username, w.Addition.Password)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send webhook request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webhook request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request, error) {
|
||||
// 替换模板中的占位符
|
||||
body := w.replaceTemplate(w.Addition.Body, message, title)
|
||||
|
||||
contentType := w.Addition.ContentType
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", w.Addition.URL, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (w *WebhookSender) createGETRequest(message, title string) (*http.Request, error) {
|
||||
URL := w.replaceTemplate(w.Addition.Body, message, title)
|
||||
u, err := url.Parse(URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符
|
||||
func (w *WebhookSender) replaceTemplate(template, message, title string) string {
|
||||
result := template
|
||||
result = strings.ReplaceAll(result, "{{message}}", message)
|
||||
result = strings.ReplaceAll(result, "{{title}}", title)
|
||||
return result
|
||||
}
|
||||
@@ -7,8 +7,8 @@ type IOidcProvider interface {
|
||||
// 请务必返回 &Configuration{} 的指针
|
||||
GetConfiguration() Configuration
|
||||
// 获取授权URL和状态
|
||||
GetAuthorizationURL() (string, string)
|
||||
OnCallback(ctx context.Context, state string, query map[string]string) (OidcCallback, error)
|
||||
GetAuthorizationURL(redirectURI string) (string, string)
|
||||
OnCallback(ctx context.Context, state string, query map[string]string, callbackURI string) (OidcCallback, error)
|
||||
Init() error
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
@@ -20,21 +21,22 @@ func (g *Generic) GetConfiguration() factory.Configuration {
|
||||
return &g.Addition
|
||||
}
|
||||
|
||||
func (g *Generic) GetAuthorizationURL() (string, string) {
|
||||
func (g *Generic) GetAuthorizationURL(redirectURI string) (string, string) {
|
||||
state := utils.GenerateRandomString(16)
|
||||
|
||||
// 构建GitHub OAuth授权URL
|
||||
authURL := fmt.Sprintf(
|
||||
"%s?client_id=%s&state=%s&scope=%s&response_type=code",
|
||||
"%s?client_id=%s&state=%s&scope=%s&redirect_uri=%s&response_type=code",
|
||||
g.Addition.AuthURL,
|
||||
url.QueryEscape(g.Addition.ClientId),
|
||||
url.QueryEscape(state),
|
||||
url.QueryEscape(g.Addition.Scope),
|
||||
url.QueryEscape(redirectURI),
|
||||
)
|
||||
g.stateCache.Set(state, true, cache.DefaultExpiration)
|
||||
return authURL, state
|
||||
}
|
||||
func (g *Generic) OnCallback(ctx context.Context, state string, query map[string]string) (factory.OidcCallback, error) {
|
||||
func (g *Generic) OnCallback(ctx context.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
|
||||
code := query["code"]
|
||||
|
||||
// 验证state防止CSRF攻击
|
||||
@@ -58,12 +60,13 @@ func (g *Generic) OnCallback(ctx context.Context, state string, query map[string
|
||||
"client_id": {g.Addition.ClientId},
|
||||
"client_secret": {g.Addition.ClientSecret},
|
||||
"code": {code},
|
||||
"redirect_uri": {callbackURI},
|
||||
"grant_type": {"authorization_code"},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("POST", g.Addition.TokenURL, nil)
|
||||
req.URL.RawQuery = data.Encode()
|
||||
req, _ := http.NewRequest("POST", g.Addition.TokenURL, strings.NewReader(data.Encode()))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -24,7 +24,7 @@ func (g *Github) GetConfiguration() factory.Configuration {
|
||||
return &g.Addition
|
||||
}
|
||||
|
||||
func (g *Github) GetAuthorizationURL() (string, string) {
|
||||
func (g *Github) GetAuthorizationURL(_ string) (string, string) {
|
||||
state := utils.GenerateRandomString(16)
|
||||
|
||||
// 构建GitHub OAuth授权URL
|
||||
@@ -36,7 +36,7 @@ func (g *Github) GetAuthorizationURL() (string, string) {
|
||||
g.stateCache.Set(state, true, cache.NoExpiration)
|
||||
return authURL, state
|
||||
}
|
||||
func (g *Github) OnCallback(ctx context.Context, state string, query map[string]string) (factory.OidcCallback, error) {
|
||||
func (g *Github) OnCallback(ctx context.Context, state string, query map[string]string, _ string) (factory.OidcCallback, error) {
|
||||
code := query["code"]
|
||||
|
||||
// 验证state防止CSRF攻击
|
||||
|
||||
@@ -27,9 +27,6 @@ func CurrentProvider() factory.IOidcProvider {
|
||||
func LoadProvider(name string, configJson string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if currentProvider != nil && currentProvider.GetName() == name {
|
||||
return nil // 已经加载了相同的提供程序
|
||||
}
|
||||
if currentProvider != nil {
|
||||
if err := currentProvider.Destroy(); err != nil {
|
||||
log.Printf("Failed to destroy provider %s: %v", currentProvider.GetName(), err)
|
||||
|
||||
+1
-3
@@ -16,9 +16,7 @@ func GetClients(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
CheckOrigin: CheckOrigin,
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func CheckOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
host := r.Host
|
||||
originUrl, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return originUrl.Host == host
|
||||
}
|
||||
Reference in new issue
Block a user